diff --git a/.gitignore b/.gitignore index fd3844d9..46f90243 100644 --- a/.gitignore +++ b/.gitignore @@ -81,9 +81,14 @@ CLAUDE.md BUGS_AND_FEATURES.md frontend/TEST_PLAN.md docs/REFACTORING_PLAN.md +docs/MULTIPLE_ADMINISTRATORS_PLAN.md +docs/*_PLAN.md docs/test-*.md docs/feature-*.md +# Local backup directory (from testing) +backup/ + # Local artifacts from browser tooling .playwright-mcp/ diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index aa5122e0..9d13a68d 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -450,6 +450,20 @@ ADMIN_EMAIL=your-email@yourdomain.com For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution. +### Routing Schema + +PicPeak consists of two services that need to be routed correctly: + +| Path | Service | Port | Description | +|------|---------|------|-------------| +| `/api/*` | Backend | 3001 | All API endpoints | +| `/photos/*` | Backend | 3001 | Protected photo files | +| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files | +| `/uploads/*` | Backend | 3001 | Upload files | +| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) | + +> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests. + ### Option 1: Nginx Install nginx and create `/etc/nginx/sites-available/picpeak`: @@ -468,39 +482,32 @@ server { ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; - # Frontend - location / { - proxy_pass http://localhost:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Frontend (serves UI and /admin/*) - location / { - proxy_pass http://localhost:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Backend API and protected resources - location /api { + # Backend: API endpoints + location /api/ { proxy_pass http://localhost:3001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } - location ~ ^/(photos|thumbnails|uploads) { + + # Backend: Protected media files + location ~ ^/(photos|thumbnails|uploads)/ { proxy_pass http://localhost:3001; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } + + # Frontend: Everything else (React SPA) + location / { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } } ``` @@ -530,10 +537,16 @@ services: backend: labels: - "traefik.enable=true" + # API endpoints - "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)" - "traefik.http.routers.picpeak-api.entrypoints=websecure" - "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt" - "traefik.http.services.picpeak-api.loadbalancer.server.port=3001" + # Protected media files + - "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))" + - "traefik.http.routers.picpeak-media.entrypoints=websecure" + - "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt" + - "traefik.http.services.picpeak-media.loadbalancer.server.port=3001" ``` ### Option 3: Caddy @@ -542,21 +555,12 @@ Create a `Caddyfile`: ```caddyfile your-domain.com { - # Frontend - handle /* { - reverse_proxy localhost:3000 - } - - # Backend API and admin + # Backend: API endpoints handle /api/* { reverse_proxy localhost:3001 } - - handle /admin/* { - reverse_proxy localhost:3001 - } - # Protected resources + # Backend: Protected media files handle /photos/* { reverse_proxy localhost:3001 } @@ -568,6 +572,11 @@ your-domain.com { handle /uploads/* { reverse_proxy localhost:3001 } + + # Frontend: Everything else (React SPA including /admin/*, /gallery/*) + handle { + reverse_proxy localhost:3000 + } } ``` diff --git a/backend/Dockerfile b/backend/Dockerfile index fc4e37d5..3a09f01e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,7 +12,8 @@ LABEL org.opencontainers.image.description="PicPeak Backend Service" LABEL org.opencontainers.image.licenses="MIT" # Upgrade npm to fix glob CVE-2025-64756 vulnerability -RUN npm install -g npm@latest +# Pin to npm 10.x which supports --omit=dev flag +RUN npm install -g npm@10 WORKDIR /app @@ -34,7 +35,8 @@ WORKDIR /app RUN apk upgrade --no-cache # Upgrade npm to fix glob CVE-2025-64756 vulnerability -RUN npm install -g npm@latest +# Pin to npm 10.x which supports --omit=dev flag +RUN npm install -g npm@10 # Install dumb-init for proper signal handling and postgresql-client for database checks RUN apk add --no-cache dumb-init postgresql-client @@ -46,8 +48,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules COPY --chown=nodejs:nodejs . . -# Make wait script executable -RUN chmod +x wait-for-db.sh +# Ensure all source files are readable and wait script is executable +RUN chmod -R a+r /app && chmod +x wait-for-db.sh # Create necessary directories RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \ diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db deleted file mode 100644 index 2a0b5003..00000000 Binary files a/backend/data/photo_sharing.db and /dev/null differ diff --git a/backend/migrations/core/054_add_roles_table.js b/backend/migrations/core/054_add_roles_table.js new file mode 100644 index 00000000..28026ed0 --- /dev/null +++ b/backend/migrations/core/054_add_roles_table.js @@ -0,0 +1,91 @@ +/** + * Migration: Add Roles Table + * Creates the roles table for RBAC multi-administrator support. + * + * Default roles: + * - super_admin (priority 100): Full system access including user management + * - admin (priority 80): Full event and photo management + * - editor (priority 50): Can edit events and photos but not create or delete + * - viewer (priority 20): Read-only access to dashboard and events + */ + +exports.up = async function(knex) { + console.log('Creating roles table...'); + + // Check if table already exists + const hasRolesTable = await knex.schema.hasTable('roles'); + + if (!hasRolesTable) { + await knex.schema.createTable('roles', (table) => { + table.increments('id').primary(); + table.string('name', 50).unique().notNullable(); // 'super_admin', 'admin', 'editor', 'viewer' + table.string('display_name', 100).notNullable(); // 'Super Admin', 'Admin', etc. + table.text('description'); + table.boolean('is_system').defaultTo(false); // System roles cannot be deleted + table.integer('priority').defaultTo(0); // Higher = more privileged (for hierarchy) + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // Index for name lookups + table.index(['name']); + // Index for priority-based ordering + table.index(['priority']); + }); + + console.log('Roles table created'); + } + + // Insert default system roles + const existingRoles = await knex('roles').select('name'); + const existingRoleNames = existingRoles.map(r => r.name); + + const defaultRoles = [ + { + name: 'super_admin', + display_name: 'Super Admin', + description: 'Full system access including user management', + is_system: true, + priority: 100 + }, + { + name: 'admin', + display_name: 'Admin', + description: 'Full event and photo management', + is_system: true, + priority: 80 + }, + { + name: 'editor', + display_name: 'Editor', + description: 'Can edit events and photos but not create or delete', + is_system: true, + priority: 50 + }, + { + name: 'viewer', + display_name: 'Viewer', + description: 'Read-only access to dashboard and events', + is_system: true, + priority: 20 + } + ]; + + const rolesToInsert = defaultRoles.filter(role => !existingRoleNames.includes(role.name)); + + if (rolesToInsert.length > 0) { + await knex('roles').insert(rolesToInsert); + console.log(`Inserted ${rolesToInsert.length} default roles`); + } + + console.log('Roles table migration completed successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing roles table...'); + + // Note: This will fail if there are foreign key references + // The role_permissions and admin_users tables must be rolled back first + await knex.schema.dropTableIfExists('roles'); + + console.log('Roles table removed'); +}; diff --git a/backend/migrations/core/055_add_permissions_table.js b/backend/migrations/core/055_add_permissions_table.js new file mode 100644 index 00000000..c9367734 --- /dev/null +++ b/backend/migrations/core/055_add_permissions_table.js @@ -0,0 +1,122 @@ +/** + * Migration: Add Permissions Table + * Creates the permissions table for granular access control. + * + * Permission categories: + * - events: View, create, edit, delete, archive events + * - photos: View, upload, edit, delete, download photos + * - archives: View, restore, download, delete archives + * - analytics: View analytics and statistics + * - email: View, edit, send emails + * - branding: View and edit branding settings + * - cms: View and edit CMS pages + * - settings: View and edit application settings + * - backup: View, create, restore, delete backups + * - users: View, create, edit, delete admin users (Super Admin only) + * - activity: View and export activity logs + */ + +exports.up = async function(knex) { + console.log('Creating permissions table...'); + + // Check if table already exists + const hasPermissionsTable = await knex.schema.hasTable('permissions'); + + if (!hasPermissionsTable) { + await knex.schema.createTable('permissions', (table) => { + table.increments('id').primary(); + table.string('name', 100).unique().notNullable(); // 'events.create', 'users.manage', etc. + table.string('display_name', 150).notNullable(); + table.string('category', 50).notNullable(); // 'events', 'photos', 'users', 'settings' + table.text('description'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + // Indexes for efficient lookups + table.index(['name']); + table.index(['category']); + }); + + console.log('Permissions table created'); + } + + // Check for existing permissions + const existingPermissions = await knex('permissions').select('name'); + const existingPermissionNames = existingPermissions.map(p => p.name); + + // Define all permissions + const permissions = [ + // Events + { name: 'events.view', display_name: 'View Events', category: 'events', description: 'View event list and details' }, + { name: 'events.create', display_name: 'Create Events', category: 'events', description: 'Create new events' }, + { name: 'events.edit', display_name: 'Edit Events', category: 'events', description: 'Edit existing events' }, + { name: 'events.delete', display_name: 'Delete Events', category: 'events', description: 'Delete events' }, + { name: 'events.archive', display_name: 'Archive Events', category: 'events', description: 'Archive and restore events' }, + + // Photos + { name: 'photos.view', display_name: 'View Photos', category: 'photos', description: 'View photos in events' }, + { name: 'photos.upload', display_name: 'Upload Photos', category: 'photos', description: 'Upload photos to events' }, + { name: 'photos.edit', display_name: 'Edit Photos', category: 'photos', description: 'Edit photo metadata and categories' }, + { name: 'photos.delete', display_name: 'Delete Photos', category: 'photos', description: 'Delete photos from events' }, + { name: 'photos.download', display_name: 'Download Photos', category: 'photos', description: 'Download photos and bulk export' }, + + // Archives + { name: 'archives.view', display_name: 'View Archives', category: 'archives', description: 'View archived events' }, + { name: 'archives.restore', display_name: 'Restore Archives', category: 'archives', description: 'Restore archived events' }, + { name: 'archives.download', display_name: 'Download Archives', category: 'archives', description: 'Download archive files' }, + { name: 'archives.delete', display_name: 'Delete Archives', category: 'archives', description: 'Permanently delete archives' }, + + // Analytics + { name: 'analytics.view', display_name: 'View Analytics', category: 'analytics', description: 'View analytics and statistics' }, + + // Email + { name: 'email.view', display_name: 'View Email Settings', category: 'email', description: 'View email configuration' }, + { name: 'email.edit', display_name: 'Edit Email Settings', category: 'email', description: 'Configure email settings and templates' }, + { name: 'email.send', display_name: 'Send Emails', category: 'email', description: 'Send and resend gallery emails' }, + + // Branding & CMS + { name: 'branding.view', display_name: 'View Branding', category: 'branding', description: 'View branding settings' }, + { name: 'branding.edit', display_name: 'Edit Branding', category: 'branding', description: 'Edit branding and theme settings' }, + { name: 'cms.view', display_name: 'View CMS Pages', category: 'cms', description: 'View CMS content pages' }, + { name: 'cms.edit', display_name: 'Edit CMS Pages', category: 'cms', description: 'Edit CMS content pages' }, + + // Settings + { name: 'settings.view', display_name: 'View Settings', category: 'settings', description: 'View application settings' }, + { name: 'settings.edit', display_name: 'Edit Settings', category: 'settings', description: 'Modify application settings' }, + + // Backup + { name: 'backup.view', display_name: 'View Backups', category: 'backup', description: 'View backup status and history' }, + { name: 'backup.create', display_name: 'Create Backups', category: 'backup', description: 'Create new backups' }, + { name: 'backup.restore', display_name: 'Restore Backups', category: 'backup', description: 'Restore from backups' }, + { name: 'backup.delete', display_name: 'Delete Backups', category: 'backup', description: 'Delete backup files' }, + + // User Management (Super Admin only) + { name: 'users.view', display_name: 'View Users', category: 'users', description: 'View admin user list' }, + { name: 'users.create', display_name: 'Create Users', category: 'users', description: 'Invite new admin users' }, + { name: 'users.edit', display_name: 'Edit Users', category: 'users', description: 'Edit admin user details and roles' }, + { name: 'users.delete', display_name: 'Delete Users', category: 'users', description: 'Deactivate or delete admin users' }, + + // Activity Logs + { name: 'activity.view', display_name: 'View Activity Logs', category: 'activity', description: 'View system activity logs' }, + { name: 'activity.export', display_name: 'Export Activity Logs', category: 'activity', description: 'Export activity logs' } + ]; + + // Filter out already existing permissions + const permissionsToInsert = permissions.filter(p => !existingPermissionNames.includes(p.name)); + + if (permissionsToInsert.length > 0) { + await knex('permissions').insert(permissionsToInsert); + console.log(`Inserted ${permissionsToInsert.length} permissions`); + } + + console.log('Permissions table migration completed successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing permissions table...'); + + // Note: This will fail if there are foreign key references + // The role_permissions table must be rolled back first + await knex.schema.dropTableIfExists('permissions'); + + console.log('Permissions table removed'); +}; diff --git a/backend/migrations/core/056_add_role_permissions_table.js b/backend/migrations/core/056_add_role_permissions_table.js new file mode 100644 index 00000000..8288558f --- /dev/null +++ b/backend/migrations/core/056_add_role_permissions_table.js @@ -0,0 +1,134 @@ +/** + * Migration: Add Role Permissions Junction Table + * Creates the junction table mapping permissions to roles. + * + * Role permission mappings: + * - super_admin: All permissions + * - admin: Events, Photos, Archives, Analytics, Email, Branding, CMS, Settings (view), Backup (view/create), Activity (view) + * - editor: View/Create/Edit own events and photos, Analytics (view), Activity (view) + * - viewer: View-only access to events, photos, archives, analytics, branding, cms + */ + +exports.up = async function(knex) { + console.log('Creating role_permissions junction table...'); + + // Check if table already exists + const hasRolePermissionsTable = await knex.schema.hasTable('role_permissions'); + + if (!hasRolePermissionsTable) { + await knex.schema.createTable('role_permissions', (table) => { + table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE'); + table.integer('permission_id').unsigned().references('id').inTable('permissions').onDelete('CASCADE'); + table.primary(['role_id', 'permission_id']); + + // Indexes for efficient lookups + table.index(['role_id']); + table.index(['permission_id']); + }); + + console.log('Role permissions junction table created'); + } + + // Get role and permission IDs + const roles = await knex('roles').select('id', 'name'); + const permissions = await knex('permissions').select('id', 'name'); + + if (roles.length === 0 || permissions.length === 0) { + console.log('No roles or permissions found, skipping permission mappings'); + return; + } + + const roleMap = Object.fromEntries(roles.map(r => [r.name, r.id])); + const permMap = Object.fromEntries(permissions.map(p => [p.name, p.id])); + + // Define role-permission mappings + const rolePermissions = { + super_admin: permissions.map(p => p.name), // All permissions + admin: [ + // Events - full access + 'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive', + // Photos - full access + 'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download', + // Archives - full access + 'archives.view', 'archives.restore', 'archives.download', 'archives.delete', + // Analytics - view only + 'analytics.view', + // Email - full access + 'email.view', 'email.edit', 'email.send', + // Branding - full access + 'branding.view', 'branding.edit', + // CMS - full access + 'cms.view', 'cms.edit', + // Settings - view only + 'settings.view', + // Backup - view and create only + 'backup.view', 'backup.create', + // Activity - view only + 'activity.view' + ], + editor: [ + // Events - view, create, and edit (can only see their own events) + 'events.view', 'events.create', 'events.edit', + // Photos - view, upload, edit (no delete) + 'photos.view', 'photos.upload', 'photos.edit', + // Analytics - view only + 'analytics.view', + // Activity - view only + 'activity.view' + ], + viewer: [ + // Events - view only + 'events.view', + // Photos - view only + 'photos.view', + // Archives - view only + 'archives.view', + // Analytics - view only + 'analytics.view', + // Branding - view only + 'branding.view', + // CMS - view only + 'cms.view' + ] + }; + + // Check for existing mappings to avoid duplicates + const existingMappings = await knex('role_permissions').select('role_id', 'permission_id'); + const existingSet = new Set(existingMappings.map(m => `${m.role_id}-${m.permission_id}`)); + + // Build insert list + const inserts = []; + for (const [roleName, perms] of Object.entries(rolePermissions)) { + for (const permName of perms) { + if (roleMap[roleName] && permMap[permName]) { + const key = `${roleMap[roleName]}-${permMap[permName]}`; + if (!existingSet.has(key)) { + inserts.push({ + role_id: roleMap[roleName], + permission_id: permMap[permName] + }); + } + } + } + } + + if (inserts.length > 0) { + // Insert in batches to avoid hitting database limits + const batchSize = 50; + for (let i = 0; i < inserts.length; i += batchSize) { + const batch = inserts.slice(i, i + batchSize); + await knex('role_permissions').insert(batch); + } + console.log(`Inserted ${inserts.length} role-permission mappings`); + } + + console.log('Role permissions junction table migration completed successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing role_permissions junction table...'); + + await knex.schema.dropTableIfExists('role_permissions'); + + console.log('Role permissions junction table removed'); +}; diff --git a/backend/migrations/core/057_add_role_to_admin_users.js b/backend/migrations/core/057_add_role_to_admin_users.js new file mode 100644 index 00000000..c418c186 --- /dev/null +++ b/backend/migrations/core/057_add_role_to_admin_users.js @@ -0,0 +1,115 @@ +/** + * Migration: Add Role to Admin Users + * Adds RBAC-related columns to the admin_users table: + * - role_id: Foreign key to roles table + * - created_by: Foreign key to admin_users (who invited this user) + * - invite_token: Token for invitation acceptance (64 chars = 256 bits) + * - invite_expires_at: When the invitation token expires + * - invite_accepted_at: When the user accepted the invitation + * + * Also migrates existing admin users to super_admin role. + */ + +exports.up = async function(knex) { + console.log('Adding role columns to admin_users table...'); + + // Check if columns already exist + const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id'); + const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by'); + const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token'); + const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at'); + const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at'); + + // Add new columns if they don't exist + if (!hasRoleId || !hasCreatedBy || !hasInviteToken || !hasInviteExpiresAt || !hasInviteAcceptedAt) { + await knex.schema.alterTable('admin_users', (table) => { + if (!hasRoleId) { + // Note: We add as nullable first, then set values, then alter to not null + table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL'); + } + if (!hasCreatedBy) { + table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL'); + } + if (!hasInviteToken) { + // 64 characters = 32 bytes hex = 256 bits of entropy (cryptographically secure) + table.string('invite_token', 64); + } + if (!hasInviteExpiresAt) { + table.timestamp('invite_expires_at'); + } + if (!hasInviteAcceptedAt) { + table.timestamp('invite_accepted_at'); + } + }); + + console.log('Role columns added to admin_users table'); + } + + // Add index on invite_token for fast lookup + const hasInviteTokenIndex = await knex.schema.hasColumn('admin_users', 'invite_token'); + if (hasInviteTokenIndex) { + // Create index if it doesn't exist (safe for both PostgreSQL and SQLite) + try { + await knex.schema.alterTable('admin_users', (table) => { + table.index(['invite_token']); + }); + } catch (e) { + // Index may already exist + if (!e.message.includes('already exists')) { + console.log('Note: invite_token index may already exist'); + } + } + } + + // Get super_admin role ID + const superAdminRole = await knex('roles').where('name', 'super_admin').first(); + + if (superAdminRole) { + // Migrate existing admin users without a role to super_admin + const usersWithoutRole = await knex('admin_users') + .whereNull('role_id') + .select('id'); + + if (usersWithoutRole.length > 0) { + await knex('admin_users') + .whereNull('role_id') + .update({ role_id: superAdminRole.id }); + + console.log(`Migrated ${usersWithoutRole.length} existing admin user(s) to super_admin role`); + } + } else { + console.log('Warning: super_admin role not found. Run migration 054 first.'); + } + + console.log('Admin users role migration completed successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing role columns from admin_users table...'); + + const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id'); + const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by'); + const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token'); + const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at'); + const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at'); + + await knex.schema.alterTable('admin_users', (table) => { + if (hasInviteAcceptedAt) { + table.dropColumn('invite_accepted_at'); + } + if (hasInviteExpiresAt) { + table.dropColumn('invite_expires_at'); + } + if (hasInviteToken) { + table.dropColumn('invite_token'); + } + if (hasCreatedBy) { + table.dropColumn('created_by'); + } + if (hasRoleId) { + table.dropColumn('role_id'); + } + }); + + console.log('Role columns removed from admin_users table'); +}; diff --git a/backend/migrations/core/058_add_admin_invitations_table.js b/backend/migrations/core/058_add_admin_invitations_table.js new file mode 100644 index 00000000..9b97c7bd --- /dev/null +++ b/backend/migrations/core/058_add_admin_invitations_table.js @@ -0,0 +1,68 @@ +/** + * Migration: Add Admin Invitations Table + * Creates the admin_invitations table for managing pending admin user invitations. + * + * Security features: + * - Token is 64 characters (32 bytes hex = 256 bits of entropy) + * - Tokens are unique and indexed for fast lookup + * - Invitations have expiration timestamps + * - Tracks who invited whom and when accepted + * - Foreign key constraints with appropriate CASCADE behavior + */ + +exports.up = async function(knex) { + console.log('Creating admin_invitations table...'); + + // Check if table already exists + const hasAdminInvitationsTable = await knex.schema.hasTable('admin_invitations'); + + if (!hasAdminInvitationsTable) { + await knex.schema.createTable('admin_invitations', (table) => { + table.increments('id').primary(); + + // Email of the invited user + table.string('email', 255).notNullable(); + + // Invitation token - 64 characters = 32 bytes hex = 256 bits of entropy + // Cryptographically secure for one-time use tokens + table.string('token', 64).unique().notNullable(); + + // Role to assign when invitation is accepted + table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE').notNullable(); + + // Who created this invitation + table.integer('invited_by').unsigned().references('id').inTable('admin_users').onDelete('CASCADE').notNullable(); + + // When the invitation expires (typically 7 days from creation) + table.timestamp('expires_at').notNullable(); + + // When the invitation was accepted (null if pending) + table.timestamp('accepted_at'); + + // The admin_user ID created when invitation was accepted (for audit trail) + table.integer('accepted_user_id').unsigned().references('id').inTable('admin_users').onDelete('SET NULL'); + + // When the invitation was created + table.timestamp('created_at').defaultTo(knex.fn.now()); + + // Indexes for efficient lookups + table.index(['token']); // Fast token validation + table.index(['email']); // Check for existing invitations by email + table.index(['expires_at']); // Cleanup expired invitations + table.index(['invited_by']); // List invitations by inviter + table.index(['accepted_at']); // Filter pending vs accepted + }); + + console.log('Admin invitations table created'); + } + + console.log('Admin invitations table migration completed successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing admin_invitations table...'); + + await knex.schema.dropTableIfExists('admin_invitations'); + + console.log('Admin invitations table removed'); +}; diff --git a/backend/migrations/core/059_add_admin_email_templates.js b/backend/migrations/core/059_add_admin_email_templates.js new file mode 100644 index 00000000..bd25b1db --- /dev/null +++ b/backend/migrations/core/059_add_admin_email_templates.js @@ -0,0 +1,239 @@ +/** + * Migration to add email templates for admin invitation and password reset + * These templates support the RBAC (Role-Based Access Control) feature + */ +exports.up = async function(knex) { + // Check which templates already exist + const existingTemplates = await knex('email_templates') + .select('template_key') + .whereIn('template_key', ['admin_invitation', 'admin_password_reset']); + + const existingKeys = existingTemplates.map(t => t.template_key); + + // Admin Invitation Email Template + if (!existingKeys.includes('admin_invitation')) { + await knex('email_templates').insert({ + template_key: 'admin_invitation', + subject_en: 'You have been invited to join PicPeak as {{role_name}}', + subject_de: 'Sie wurden eingeladen, PicPeak als {{role_name}} beizutreten', + body_html_en: ` +
You have been invited to join the PicPeak photo sharing platform as a {{role_name}}.
+ +Your Role: {{role_name}}
+This role grants you access to manage and administer the photo sharing platform.
+To accept this invitation and set up your account, click the button below:
+ + + +Important: This invitation expires on {{expires_at}}. Please accept the invitation before this date.
+If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.
+ +
+ If the button above does not work, copy and paste this link into your browser:
+ {{invite_link}}
+
Best regards,
+The PicPeak Team
Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als {{role_name}} beizutreten.
+ +Ihre Rolle: {{role_name}}
+Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.
+Um diese Einladung anzunehmen und Ihr Konto einzurichten, klicken Sie auf die Schaltflache unten:
+ + + +Wichtig: Diese Einladung lauft am {{expires_at}} ab. Bitte nehmen Sie die Einladung vor diesem Datum an.
+Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.
+ +
+ Wenn die Schaltflache oben nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:
+ {{invite_link}}
+
Mit freundlichen Grussen,
+Ihr PicPeak-Team
Hello {{username}},
+ +Your administrator password for PicPeak has been reset by a system administrator.
+ +{{new_password}}Security Notice
+To log in to the admin panel, click the button below:
+ +After logging in, navigate to your profile settings to change your password to something secure that only you know.
+ +Best regards,
+The PicPeak Team
Hallo {{username}},
+ +Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.
+ +{{new_password}}Sicherheitshinweis
+Um sich im Admin-Panel anzumelden, klicken Sie auf die Schaltflache unten:
+ +Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.
+ +Mit freundlichen Grussen,
+Ihr PicPeak-Team