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: ` +

Welcome to PicPeak!

+ +

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:

+ +
+ Accept Invitation +
+ +
+

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

`, + body_text_en: `Welcome to PicPeak! + +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, visit the following link: +{{invite_link}} + +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. + +Best regards, +The PicPeak Team`, + body_html_de: ` +

Willkommen bei PicPeak!

+ +

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:

+ +
+ Einladung annehmen +
+ +
+

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

`, + body_text_de: `Willkommen bei PicPeak! + +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, besuchen Sie den folgenden Link: +{{invite_link}} + +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. + +Mit freundlichen Grussen, +Ihr PicPeak-Team`, + variables: JSON.stringify(['invite_link', 'role_name', 'expires_at']) + }); + } + + // Admin Password Reset Email Template + if (!existingKeys.includes('admin_password_reset')) { + await knex('email_templates').insert({ + template_key: 'admin_password_reset', + subject_en: 'Your PicPeak administrator password has been reset', + subject_de: 'Ihr PicPeak-Administratorpasswort wurde zuruckgesetzt', + body_html_en: ` +

Password Reset Notification

+ +

Hello {{username}},

+ +

Your administrator password for PicPeak has been reset by a system administrator.

+ +
+

Your New Login Credentials:

+ +
+ +
+

Security Notice

+ +
+ +

To log in to the admin panel, click the button below:

+ +
+ Log In Now +
+ +

After logging in, navigate to your profile settings to change your password to something secure that only you know.

+ +

Best regards,
+The PicPeak Team

`, + body_text_en: `Password Reset Notification + +Hello {{username}}, + +Your administrator password for PicPeak has been reset by a system administrator. + +Your New Login Credentials: +- Username: {{username}} +- Temporary Password: {{new_password}} + +SECURITY NOTICE: +- This is a temporary password. Please change it immediately after logging in. +- Never share your password with anyone. +- If you did not request this password reset, please contact your system administrator immediately. + +To log in to the admin panel, visit: {{admin_login_url}} + +After logging in, navigate to your profile settings to change your password to something secure that only you know. + +Best regards, +The PicPeak Team`, + body_html_de: ` +

Benachrichtigung uber Passwortzurucksetzung

+ +

Hallo {{username}},

+ +

Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.

+ +
+

Ihre neuen Anmeldedaten:

+ +
+ +
+

Sicherheitshinweis

+ +
+ +

Um sich im Admin-Panel anzumelden, klicken Sie auf die Schaltflache unten:

+ +
+ Jetzt anmelden +
+ +

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

`, + body_text_de: `Benachrichtigung uber Passwortzurucksetzung + +Hallo {{username}}, + +Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt. + +Ihre neuen Anmeldedaten: +- Benutzername: {{username}} +- Vorlaufiges Passwort: {{new_password}} + +SICHERHEITSHINWEIS: +- Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung. +- Teilen Sie Ihr Passwort niemals mit anderen. +- Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator. + +Um sich im Admin-Panel anzumelden, besuchen Sie: {{admin_login_url}} + +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`, + variables: JSON.stringify(['username', 'new_password', 'admin_login_url']) + }); + } +}; + +exports.down = async function(knex) { + // Remove the admin email templates + await knex('email_templates') + .whereIn('template_key', ['admin_invitation', 'admin_password_reset']) + .delete(); +}; diff --git a/backend/migrations/core/060_add_events_created_by.js b/backend/migrations/core/060_add_events_created_by.js new file mode 100644 index 00000000..43f8431d --- /dev/null +++ b/backend/migrations/core/060_add_events_created_by.js @@ -0,0 +1,23 @@ +/** + * Migration: Add created_by column to events table + * This allows filtering events by owner for role-based access control + */ + +exports.up = async function(knex) { + // Add created_by column to events table + await knex.schema.alterTable('events', (table) => { + table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL'); + }); + + // Set existing events to be owned by the first admin (super_admin) + const superAdmin = await knex('admin_users').where('role_id', 1).first(); + if (superAdmin) { + await knex('events').update({ created_by: superAdmin.id }); + } +}; + +exports.down = async function(knex) { + await knex.schema.alterTable('events', (table) => { + table.dropColumn('created_by'); + }); +}; diff --git a/backend/package-lock.json b/backend/package-lock.json index 89d39dd4..eaaf1337 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -30,6 +30,7 @@ "i18next": "25.3.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "ipaddr.js": "^2.3.0", "joi": "^17.9.1", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.0", @@ -258,33 +259,33 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.962.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.962.0.tgz", - "integrity": "sha512-I2/1McBZCcM3PfM4ck8D6gnZR3K7+yl1fGkwTq/3ThEn9tdLjNwcdgTbPfxfX6LoecLrH9Ekoo+D9nmQ0T261w==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.964.0.tgz", + "integrity": "sha512-mDK+3qpfHnEPXeF6D8nQkJOkOvchllQosgfxv0FK9PNBuU9WVkP8yj7y3YwH6JYTgy1ejz1Ju/YfoUbbE6m7zw==", "license": "Apache-2.0", "peer": true, "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.957.0", - "@aws-sdk/credential-provider-node": "3.962.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/credential-provider-node": "3.964.0", "@aws-sdk/middleware-bucket-endpoint": "3.957.0", "@aws-sdk/middleware-expect-continue": "3.957.0", - "@aws-sdk/middleware-flexible-checksums": "3.957.0", + "@aws-sdk/middleware-flexible-checksums": "3.964.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-location-constraint": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", - "@aws-sdk/middleware-sdk-s3": "3.957.0", + "@aws-sdk/middleware-sdk-s3": "3.964.0", "@aws-sdk/middleware-ssec": "3.957.0", - "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.964.0", "@aws-sdk/region-config-resolver": "3.957.0", - "@aws-sdk/signature-v4-multi-region": "3.957.0", + "@aws-sdk/signature-v4-multi-region": "3.964.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", - "@aws-sdk/util-user-agent-node": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.964.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/eventstream-serde-browser": "^4.2.7", @@ -325,23 +326,23 @@ } }, "node_modules/@aws-sdk/client-sso": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz", - "integrity": "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.964.0.tgz", + "integrity": "sha512-IenVyY8Io2CwBgmS22xk/H5LibmSbvLnPA9oFqLORO6Ji1Ks8z/ow+ud/ZurVjFekz3LD/uxVFX3ZKGo6N7Byw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.957.0", + "@aws-sdk/core": "3.964.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", - "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.964.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", - "@aws-sdk/util-user-agent-node": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.964.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", @@ -374,9 +375,9 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.957.0.tgz", - "integrity": "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.964.0.tgz", + "integrity": "sha512-1gIfbt0KRxI8am1UYFcIxQ5QKb22JyN3k52sxyrKXJYC8Knn/rTUAZbYti45CfETe5PLadInGvWqClwGRlZKNg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.957.0", @@ -411,12 +412,12 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.957.0.tgz", - "integrity": "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.964.0.tgz", + "integrity": "sha512-jWNSXOOBMYuxzI2rXi8x91YL07dhomyGzzh0CdaLej0LRmknmDrZcZNkVpa7Fredy1PFcmOlokwCS5PmZMN8ZQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", + "@aws-sdk/core": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/types": "^4.11.0", @@ -427,12 +428,12 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.957.0.tgz", - "integrity": "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.964.0.tgz", + "integrity": "sha512-up7dl6vcaoXuYSwGXDvx8RnF8Lwj3jGChhyUR7krZOXLarIfUUN3ILOZnVNK5s/HnVNkEILlkdPvjhr9LVC1/Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", + "@aws-sdk/core": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/node-http-handler": "^4.4.7", @@ -448,19 +449,19 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.962.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.962.0.tgz", - "integrity": "sha512-h0kVnXLW2d3nxbcrR/Pfg3W/+YoCguasWz7/3nYzVqmdKarGrpJzaFdoZtLgvDSZ8VgWUC4lWOTcsDMV0UNqUQ==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.964.0.tgz", + "integrity": "sha512-t4FN9qTWU4nXDU6EQ6jopvyhXw0dbQ3n+3g6x5hmc1ECFAqA+xmFd1i5LljdZCi79cUXHduQWwvW8RJHMf0qJw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/credential-provider-env": "3.957.0", - "@aws-sdk/credential-provider-http": "3.957.0", - "@aws-sdk/credential-provider-login": "3.962.0", - "@aws-sdk/credential-provider-process": "3.957.0", - "@aws-sdk/credential-provider-sso": "3.958.0", - "@aws-sdk/credential-provider-web-identity": "3.958.0", - "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/credential-provider-env": "3.964.0", + "@aws-sdk/credential-provider-http": "3.964.0", + "@aws-sdk/credential-provider-login": "3.964.0", + "@aws-sdk/credential-provider-process": "3.964.0", + "@aws-sdk/credential-provider-sso": "3.964.0", + "@aws-sdk/credential-provider-web-identity": "3.964.0", + "@aws-sdk/nested-clients": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/credential-provider-imds": "^4.2.7", "@smithy/property-provider": "^4.2.7", @@ -473,13 +474,13 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.962.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.962.0.tgz", - "integrity": "sha512-kHYH6Av2UifG3mPkpPUNRh/PuX6adaAcpmsclJdHdxlixMCRdh8GNeEihq480DC0GmfqdpoSf1w2CLmLLPIS6w==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.964.0.tgz", + "integrity": "sha512-c64dmTizMkJXDRzN3NYPTmUpKxegr5lmLOYPeQ60Zcbft6HFwPme8Gwy8pNxO4gG1fw6Ja2Vu6fZuSTn8aDFOQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/nested-clients": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", @@ -492,17 +493,17 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.962.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.962.0.tgz", - "integrity": "sha512-CS78NsWRxLa+nWqeWBEYMZTLacMFIXs1C5WJuM9kD05LLiWL32ksljoPsvNN24Bc7rCSQIIMx/U3KGvkDVZMVg==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.964.0.tgz", + "integrity": "sha512-FHxDXPOj888/qc/X8s0x4aUBdp4Y3k9VePRehUJBWRhhTsAyuIJis5V0iQeY1qvtqHXYa2qd1EZHGJ3bTjHxSw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.957.0", - "@aws-sdk/credential-provider-http": "3.957.0", - "@aws-sdk/credential-provider-ini": "3.962.0", - "@aws-sdk/credential-provider-process": "3.957.0", - "@aws-sdk/credential-provider-sso": "3.958.0", - "@aws-sdk/credential-provider-web-identity": "3.958.0", + "@aws-sdk/credential-provider-env": "3.964.0", + "@aws-sdk/credential-provider-http": "3.964.0", + "@aws-sdk/credential-provider-ini": "3.964.0", + "@aws-sdk/credential-provider-process": "3.964.0", + "@aws-sdk/credential-provider-sso": "3.964.0", + "@aws-sdk/credential-provider-web-identity": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/credential-provider-imds": "^4.2.7", "@smithy/property-provider": "^4.2.7", @@ -515,12 +516,12 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.957.0.tgz", - "integrity": "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.964.0.tgz", + "integrity": "sha512-HaTLKqj3jeZY88E/iBjsNJsXgmRTTT7TghqeRiF8FKb/7UY1xEvasBO0c1xqfOye8dsyt35nTfTTyIsd/CBfww==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", + "@aws-sdk/core": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", @@ -532,14 +533,14 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.958.0.tgz", - "integrity": "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.964.0.tgz", + "integrity": "sha512-oR78TjSpjVf1IpPWQnGHEGqlnQs+K4f5nCxLK2P6JDPprXay6oknsoSiU4x2urav6VCyMPMC9KTCGjBoFKUIxQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.958.0", - "@aws-sdk/core": "3.957.0", - "@aws-sdk/token-providers": "3.958.0", + "@aws-sdk/client-sso": "3.964.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/token-providers": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", @@ -551,13 +552,13 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.958.0.tgz", - "integrity": "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.964.0.tgz", + "integrity": "sha512-07JQDmbjZjOt3nL/j1wTcvQqjmPkynQYftUV/ooZ+qTbmJXFbCBdal1VCElyeiu0AgBq9dfhw0rBBcbND1ZMlA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/nested-clients": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", @@ -569,9 +570,9 @@ } }, "node_modules/@aws-sdk/lib-storage": { - "version": "3.962.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.962.0.tgz", - "integrity": "sha512-Ai5gWRQkzsUMQ6NPoZZoiLXoQ6/yPRcR4oracIVjyWcu48TfBpsRgbqY/5zNOM55ag1wPX9TtJJGOhK3TNk45g==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.964.0.tgz", + "integrity": "sha512-ro6B04Q5TjPgIKdSWGJ+tj2ordVF1IfZJERwGpYkrwhboNEoXBXuzpfnh2LYBPvMmFJQ+8UXSFw1jkLLgxM+ig==", "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.2.7", @@ -586,7 +587,7 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@aws-sdk/client-s3": "^3.962.0" + "@aws-sdk/client-s3": "^3.964.0" } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { @@ -623,15 +624,15 @@ } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.957.0.tgz", - "integrity": "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.964.0.tgz", + "integrity": "sha512-IA2kSKkwC/HHFF75nTR7s/nWt5CboB6vMgpLpvx40Cc01cMp+06Jr7U2/+DPPc8fkCagTytchY4gX9Hzn5ej8g==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.957.0", + "@aws-sdk/core": "3.964.0", "@aws-sdk/crc64-nvme": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/is-array-buffer": "^4.2.0", @@ -707,12 +708,12 @@ } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.957.0.tgz", - "integrity": "sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.964.0.tgz", + "integrity": "sha512-SeFcLo3tUdI3amzoIiArd9O0i7vAB0n5fgbQHBu137s3SbSLO5tPspE25rrUITwlc5HTbHMK6UzBq+3hITmImA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", + "@aws-sdk/core": "3.964.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-arn-parser": "3.957.0", "@smithy/core": "^3.20.0", @@ -746,12 +747,12 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.957.0.tgz", - "integrity": "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.964.0.tgz", + "integrity": "sha512-/QyBl8WLNtqw3ucyAggumQXVCi8GRxaDGE1ElyYMmacfiwHl37S9y8JVW/QLL1lIEXGcsrhMUKV3pyFJFALA7w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", + "@aws-sdk/core": "3.964.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@smithy/core": "^3.20.0", @@ -764,23 +765,23 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.958.0.tgz", - "integrity": "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.964.0.tgz", + "integrity": "sha512-ql+ftRwjyZkZeG3qbrRJFVmNR0id83WEUqhFVjvrQMWspNApBhz0Ar4YVSn7Uv0QaKkaR7ALPtmdMzFr3/E4bQ==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.957.0", + "@aws-sdk/core": "3.964.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", - "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.964.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", - "@aws-sdk/util-user-agent-node": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.964.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", @@ -829,12 +830,12 @@ } }, "node_modules/@aws-sdk/s3-request-presigner": { - "version": "3.962.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.962.0.tgz", - "integrity": "sha512-tyxsGfLY4NSohLrJsFGXbE3j8jguWK+hdGaUQSD1gJPvmC0B82qOyJ7WBIJLWgTabU3fiF/I9EGXjzR2rKr8jQ==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.964.0.tgz", + "integrity": "sha512-gKKdIZGYV8Ohm3X8j3y6Xr2ua1oD/Wsa3N7hYro3HqcnuGvl1h+mdw0IqUU+5yEzcoM5ItLJnH+6Q8Xz+Wv9gw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/signature-v4-multi-region": "3.957.0", + "@aws-sdk/signature-v4-multi-region": "3.964.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-format-url": "3.957.0", "@smithy/middleware-endpoint": "^4.4.1", @@ -848,12 +849,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.957.0.tgz", - "integrity": "sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.964.0.tgz", + "integrity": "sha512-ASQmO9EB2ukSTGpO7B2ZceSbNVivCLqWh89o/JJtcIdGpOu8p9XHpeK3hiUz2OQo2Igw03/n8s+DNvP+N9krpw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.957.0", + "@aws-sdk/middleware-sdk-s3": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", @@ -865,13 +866,13 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.958.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.958.0.tgz", - "integrity": "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.964.0.tgz", + "integrity": "sha512-UqouLQbYepZnMFJGB/DVpA5GhF9uT98vNWSMz9PVbhgEPUKa73FECRT6YFZvZOh8kA+0JiENrnmS6d93I70ykQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.957.0", - "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/core": "3.964.0", + "@aws-sdk/nested-clients": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", @@ -963,12 +964,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.957.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.957.0.tgz", - "integrity": "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==", + "version": "3.964.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.964.0.tgz", + "integrity": "sha512-jgob8Z/bZIh1dwEgLqE12q+aCf0ieLy7anT8bWpqMijMJqsnrPBToa7smSykfom9YHrdOgrQhXswMpE75dzLRw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.964.0", "@aws-sdk/types": "3.957.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", @@ -5399,30 +5400,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6883,12 +6860,12 @@ } }, "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 10" } }, "node_modules/is-arrayish": { @@ -9661,6 +9638,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 4a0d09ba..4240c43f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -34,6 +34,7 @@ "i18next": "25.3.2", "i18next-browser-languagedetector": "^8.2.0", "i18next-http-backend": "^3.0.2", + "ipaddr.js": "^2.3.0", "joi": "^17.9.1", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.0", diff --git a/backend/server.js b/backend/server.js index d49556a5..8a277fb8 100644 --- a/backend/server.js +++ b/backend/server.js @@ -436,6 +436,8 @@ app.use('/api/admin/photos', require('./src/routes/adminPhotos')); app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport')); app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); app.use('/api/admin/events', require('./src/routes/adminEventRename')); +app.use('/api/admin/users', require('./src/routes/adminUsers')); +app.use('/api/invite', require('./src/routes/acceptInvite')); app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public', require('./src/routes/publicCMS')); app.use('/api/images', require('./src/routes/protectedImages')); diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index fb9c5332..a1010786 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -57,32 +57,59 @@ async function adminAuth(req, res, next) { }); } - // Check if admin still exists and is active - const admin = await db('admin_users') - .where({ id: decoded.id, is_active: formatBoolean(true) }) - .first(); - + // Check if admin still exists and is active, including role info + // Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario) + let admin; + try { + admin = await db('admin_users') + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) }) + .select( + 'admin_users.id', + 'admin_users.username', + 'admin_users.email', + 'admin_users.password_changed_at', + 'roles.id as role_id', + 'roles.name as role_name' + ) + .first(); + } catch (joinError) { + // Fallback: roles table may not exist yet during upgrade + // Query without role join - user will have no role info but can still authenticate + logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message }); + admin = await db('admin_users') + .where({ id: decoded.id, is_active: formatBoolean(true) }) + .select('id', 'username', 'email', 'password_changed_at') + .first(); + if (admin) { + admin.role_id = null; + admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade + } + } + if (!admin) { return res.status(401).json({ error: 'Invalid token' }); } - + // Check if password was changed after token was issued if (admin.password_changed_at) { const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000; if (decoded.iat < passwordChangedTime) { logger.warn('Token used after password change', { userId: decoded.id }); - return res.status(401).json({ + return res.status(401).json({ error: 'Token invalid due to password change', code: 'PASSWORD_CHANGED' }); } } - - // Add user info to request + + // Add user info to request (enhanced with role) req.admin = { id: admin.id, username: admin.username, - email: admin.email + email: admin.email, + roleId: admin.role_id, + roleName: admin.role_name }; req.token = token; // Store token for potential revocation diff --git a/backend/src/middleware/permissions.js b/backend/src/middleware/permissions.js new file mode 100644 index 00000000..b9a227d3 --- /dev/null +++ b/backend/src/middleware/permissions.js @@ -0,0 +1,242 @@ +/** + * Permission Checking Middleware for RBAC + * Provides role-based access control with caching for performance + */ + +const { db } = require('../database/db'); +const { ForbiddenError } = require('../utils/errors'); +const logger = require('../utils/logger'); + +// Cache for role permissions (refreshed periodically) +let permissionCache = new Map(); +let cacheLastUpdated = 0; +const CACHE_TTL = 60000; // 1 minute + +/** + * Refresh permission cache from database + * Handles upgrade scenario where RBAC tables may not exist yet + */ +async function refreshPermissionCache() { + const now = Date.now(); + if (now - cacheLastUpdated < CACHE_TTL && permissionCache.size > 0) { + return; + } + + try { + const rolePermissions = await db('role_permissions') + .join('roles', 'roles.id', 'role_permissions.role_id') + .join('permissions', 'permissions.id', 'role_permissions.permission_id') + .select('roles.name as role_name', 'permissions.name as permission_name'); + + const newCache = new Map(); + for (const rp of rolePermissions) { + if (!newCache.has(rp.role_name)) { + newCache.set(rp.role_name, new Set()); + } + newCache.get(rp.role_name).add(rp.permission_name); + } + + permissionCache = newCache; + cacheLastUpdated = now; + } catch (error) { + // Handle case where RBAC tables don't exist yet (upgrade scenario) + // Grant super_admin all permissions by default during upgrade window + if (error.message.includes('no such table') || error.message.includes('does not exist') || error.message.includes('relation')) { + logger.warn('RBAC tables not available yet - granting full access to authenticated users during upgrade'); + const allPermissions = new Set([ + 'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive', + 'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download', + 'archives.view', 'archives.restore', 'archives.download', 'archives.delete', + 'analytics.view', 'email.view', 'email.edit', 'email.send', + 'branding.view', 'branding.edit', 'cms.view', 'cms.edit', + 'settings.view', 'settings.edit', 'backup.view', 'backup.create', 'backup.restore', 'backup.delete', + 'users.view', 'users.create', 'users.edit', 'users.delete', + 'activity.view', 'activity.export' + ]); + permissionCache.set('super_admin', allPermissions); + cacheLastUpdated = now; + } else { + logger.error('Failed to refresh permission cache', { error: error.message }); + } + } +} + +/** + * Check if a role has a specific permission + * @param {string} roleName - Role name to check + * @param {string} permissionName - Permission name to check + * @returns {Promise} + */ +async function roleHasPermission(roleName, permissionName) { + await refreshPermissionCache(); + const rolePerms = permissionCache.get(roleName); + return rolePerms ? rolePerms.has(permissionName) : false; +} + +/** + * Check if user has any of the specified permissions + * @param {number} userId - User ID to check + * @param {string[]} permissions - Array of permission names + * @returns {Promise} + */ +async function userHasAnyPermission(userId, permissions) { + const user = await db('admin_users') + .join('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', userId) + .select('roles.name as role_name') + .first(); + + if (!user) return false; + + for (const perm of permissions) { + if (await roleHasPermission(user.role_name, perm)) { + return true; + } + } + return false; +} + +/** + * Check if user has all specified permissions + * @param {number} userId - User ID to check + * @param {string[]} permissions - Array of permission names + * @returns {Promise} + */ +async function userHasAllPermissions(userId, permissions) { + const user = await db('admin_users') + .join('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', userId) + .select('roles.name as role_name') + .first(); + + if (!user) return false; + + for (const perm of permissions) { + if (!(await roleHasPermission(user.role_name, perm))) { + return false; + } + } + return true; +} + +/** + * Middleware factory: require specific permission(s) + * @param {string|string[]} permissions - Permission name(s) required + * @param {object} options - { requireAll: boolean } + * @returns {Function} Express middleware + */ +function requirePermission(permissions, options = { requireAll: false }) { + const permArray = Array.isArray(permissions) ? permissions : [permissions]; + + return async (req, res, next) => { + try { + if (!req.admin || !req.admin.id) { + throw new ForbiddenError('Authentication required'); + } + + const hasPermission = options.requireAll + ? await userHasAllPermissions(req.admin.id, permArray) + : await userHasAnyPermission(req.admin.id, permArray); + + if (!hasPermission) { + logger.warn('Permission denied', { + userId: req.admin.id, + username: req.admin.username, + requiredPermissions: permArray, + path: req.path, + method: req.method + }); + throw new ForbiddenError('Insufficient permissions'); + } + + next(); + } catch (error) { + if (error instanceof ForbiddenError) { + return res.status(403).json({ error: error.message, code: 'FORBIDDEN' }); + } + next(error); + } + }; +} + +/** + * Middleware: require super_admin role + * @returns {Function} Express middleware + */ +function requireSuperAdmin() { + return async (req, res, next) => { + try { + if (!req.admin || !req.admin.id) { + throw new ForbiddenError('Authentication required'); + } + + const user = await db('admin_users') + .join('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', req.admin.id) + .select('roles.name as role_name') + .first(); + + if (!user || user.role_name !== 'super_admin') { + logger.warn('Super admin access denied', { + userId: req.admin.id, + username: req.admin.username, + path: req.path, + method: req.method + }); + throw new ForbiddenError('Super Admin access required'); + } + + next(); + } catch (error) { + if (error instanceof ForbiddenError) { + return res.status(403).json({ error: error.message, code: 'FORBIDDEN' }); + } + next(error); + } + }; +} + +/** + * Get user's permissions for client + * @param {number} userId - User ID + * @returns {Promise<{role: object|null, permissions: string[]}>} + */ +async function getUserPermissions(userId) { + const user = await db('admin_users') + .join('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', userId) + .select('roles.name as role_name', 'roles.display_name as role_display_name') + .first(); + + if (!user) return { role: null, permissions: [] }; + + await refreshPermissionCache(); + const permissions = permissionCache.get(user.role_name) || new Set(); + + return { + role: { + name: user.role_name, + displayName: user.role_display_name + }, + permissions: Array.from(permissions) + }; +} + +/** + * Clear permission cache (useful for testing or when permissions change) + */ +function clearPermissionCache() { + permissionCache.clear(); + cacheLastUpdated = 0; +} + +module.exports = { + requirePermission, + requireSuperAdmin, + getUserPermissions, + userHasAnyPermission, + userHasAllPermissions, + roleHasPermission, + refreshPermissionCache, + clearPermissionCache +}; diff --git a/backend/src/routes/acceptInvite.js b/backend/src/routes/acceptInvite.js new file mode 100644 index 00000000..969875de --- /dev/null +++ b/backend/src/routes/acceptInvite.js @@ -0,0 +1,75 @@ +/** + * Accept Invitation Routes (Public) + * Handles invitation token validation and account creation + */ + +const express = require('express'); +const { body, param } = require('express-validator'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const { validatePasswordStrength } = require('../utils/passwordGenerator'); +const userManagementService = require('../services/userManagementService'); +const router = express.Router(); + +/** + * GET /:token + * Validate invitation token + * Public endpoint - no auth required + */ +router.get('/:token', [ + param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token') +], handleAsync(async (req, res) => { + validateRequest(req); + + const invitation = await userManagementService.validateInvitationToken(req.params.token); + + if (!invitation) { + return res.status(404).json({ error: 'Invalid or expired invitation' }); + } + + res.json({ + valid: true, + email: invitation.email, + role: invitation.role_name, + expiresAt: invitation.expires_at + }); +})); + +/** + * POST /:token + * Accept invitation and create account + * Public endpoint - no auth required + */ +router.post('/:token', [ + param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token'), + body('username') + .trim() + .isLength({ min: 3, max: 50 }) + .withMessage('Username must be 3-50 characters') + .matches(/^[a-zA-Z0-9_-]+$/) + .withMessage('Username can only contain letters, numbers, underscores, and hyphens'), + body('password') + .isLength({ min: 12 }) + .withMessage('Password must be at least 12 characters') + .custom((value) => { + const validation = validatePasswordStrength(value); + if (!validation.isValid) { + throw new Error(validation.messages.join(', ')); + } + return true; + }) +], handleAsync(async (req, res) => { + validateRequest(req); + + const result = await userManagementService.acceptInvitation({ + token: req.params.token, + username: req.body.username, + password: req.body.password + }); + + successResponse(res, { + message: 'Account created successfully. You can now log in.', + email: result.email + }, 201); +})); + +module.exports = router; diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js index 445ec6ff..ef6d2bc1 100644 --- a/backend/src/routes/adminArchives.js +++ b/backend/src/routes/adminArchives.js @@ -4,12 +4,13 @@ const fs = require('fs').promises; const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const archiver = require('archiver'); const AdmZip = require('adm-zip'); const router = express.Router(); // Get all archived events -router.get('/', adminAuth, async (req, res) => { +router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 20; @@ -81,7 +82,7 @@ router.get('/', adminAuth, async (req, res) => { }); // Get single archive details -router.get('/:id', adminAuth, async (req, res) => { +router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => { try { const archive = await db('events') .where('id', req.params.id) @@ -137,7 +138,7 @@ router.get('/:id', adminAuth, async (req, res) => { }); // Restore archive -router.post('/:id/restore', adminAuth, async (req, res) => { +router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => { try { const archive = await db('events') .where('id', req.params.id) @@ -300,7 +301,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => { }); // Download archive -router.get('/:id/download', adminAuth, async (req, res) => { +router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => { try { const archive = await db('events') .where('id', req.params.id) @@ -349,7 +350,7 @@ router.get('/:id/download', adminAuth, async (req, res) => { }); // Delete archive permanently -router.delete('/:id', adminAuth, async (req, res) => { +router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => { try { const archive = await db('events') .where('id', req.params.id) diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index 84dba64a..2539666a 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -1,6 +1,7 @@ const express = require('express'); const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService'); const logger = require('../utils/logger'); const fs = require('fs').promises; @@ -12,7 +13,7 @@ const S3StorageAdapter = require('../services/storage/s3Storage'); const router = express.Router(); // Get backup configuration -router.get('/config', adminAuth, async (req, res) => { +router.get('/config', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const settings = await db('app_settings') .where('setting_type', 'backup') @@ -35,7 +36,7 @@ router.get('/config', adminAuth, async (req, res) => { }); // Update backup configuration -router.put('/config', adminAuth, async (req, res) => { +router.put('/config', adminAuth, requirePermission('backup.create'), async (req, res) => { try { const updates = req.body; @@ -97,7 +98,7 @@ router.put('/config', adminAuth, async (req, res) => { }); // Get backup status and history -router.get('/status', adminAuth, async (req, res) => { +router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const limit = parseInt(req.query.limit) || 10; const status = await getBackupStatus(limit); @@ -110,7 +111,7 @@ router.get('/status', adminAuth, async (req, res) => { }); // Trigger manual backup -router.post('/run', adminAuth, async (req, res) => { +router.post('/run', adminAuth, requirePermission('backup.create'), async (req, res) => { try { // Check if backup is already running const status = await getBackupStatus(); @@ -131,7 +132,7 @@ router.post('/run', adminAuth, async (req, res) => { }); // Get backup run details -router.get('/runs/:id', adminAuth, async (req, res) => { +router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { id } = req.params; @@ -160,7 +161,7 @@ router.get('/runs/:id', adminAuth, async (req, res) => { }); // Get file states (for debugging/monitoring) -router.get('/files', adminAuth, async (req, res) => { +router.get('/files', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { page = 1, limit = 50, search = '' } = req.query; const offset = (page - 1) * limit; @@ -195,7 +196,7 @@ router.get('/files', adminAuth, async (req, res) => { }); // Clean up old backup runs -router.delete('/cleanup', adminAuth, async (req, res) => { +router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => { try { const { days = 30 } = req.body; @@ -209,7 +210,7 @@ router.delete('/cleanup', adminAuth, async (req, res) => { }); // Test backup destination connectivity -router.post('/test-connection', adminAuth, async (req, res) => { +router.post('/test-connection', adminAuth, requirePermission('backup.create'), async (req, res) => { try { const { destination_type, ...config } = req.body; @@ -334,7 +335,7 @@ router.post('/test-connection', adminAuth, async (req, res) => { }); // Get backup manifest for a specific backup run -router.get('/manifest/:backupRunId', adminAuth, async (req, res) => { +router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { backupRunId } = req.params; const result = await getBackupManifest(backupRunId); @@ -351,7 +352,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => { }); // Validate a backup manifest -router.post('/manifest/validate', adminAuth, async (req, res) => { +router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { manifestPath } = req.body; @@ -373,7 +374,7 @@ router.post('/manifest/validate', adminAuth, async (req, res) => { }); // Download backup manifest -router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => { +router.get('/manifest/:backupRunId/download', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { backupRunId } = req.params; const { format = 'json' } = req.query; @@ -404,7 +405,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => { }); // Get manifest for specific backup -router.get('/manifests/:backupId', adminAuth, async (req, res) => { +router.get('/manifests/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { backupId } = req.params; const result = await getBackupManifest(backupId); @@ -421,7 +422,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => { }); // Download manifest file -router.get('/manifests/:backupId/download', adminAuth, async (req, res) => { +router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { backupId } = req.params; const { format = 'json' } = req.query; @@ -452,7 +453,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => { }); // Validate a manifest -router.post('/manifests/validate', adminAuth, async (req, res) => { +router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { manifestPath, manifestData } = req.body; @@ -481,7 +482,7 @@ router.post('/manifests/validate', adminAuth, async (req, res) => { }); // List S3 buckets -router.get('/s3/buckets', adminAuth, async (req, res) => { +router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const config = await getBackupConfig(); @@ -513,7 +514,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => { }); // List files in S3 backup location -router.get('/s3/files', adminAuth, async (req, res) => { +router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { prefix = '', maxKeys = 100, continuationToken } = req.query; const config = await getBackupConfig(); @@ -550,7 +551,7 @@ router.get('/s3/files', adminAuth, async (req, res) => { }); // Clean up old S3 backups -router.delete('/s3/cleanup', adminAuth, async (req, res) => { +router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => { try { const { retentionDays = 30, dryRun = false } = req.body; const config = await getBackupConfig(); @@ -612,7 +613,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => { }); // Test S3 upload functionality -router.post('/s3/test-upload', adminAuth, async (req, res) => { +router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), async (req, res) => { try { const config = await getBackupConfig(); @@ -664,7 +665,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => { }); // Download entire backup -router.get('/download/:backupId', adminAuth, async (req, res) => { +router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { backupId } = req.params; @@ -752,7 +753,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => { }); // Get current file checksums -router.get('/checksums', adminAuth, async (req, res) => { +router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { path: targetPath = '', recursive = true } = req.query; const checksums = {}; @@ -821,7 +822,7 @@ router.get('/checksums', adminAuth, async (req, res) => { }); // Estimate backup size before running -router.post('/estimate', adminAuth, async (req, res) => { +router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req, res) => { try { const { includeArchived = true } = req.body; diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js index b5ecb1bb..0fc64085 100644 --- a/backend/src/routes/adminCMS.js +++ b/backend/src/routes/adminCMS.js @@ -2,10 +2,11 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const router = express.Router(); // Get all CMS pages -router.get('/pages', adminAuth, async (req, res) => { +router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => { try { const pages = await db('cms_pages').select('*').orderBy('slug', 'asc'); res.json(pages); @@ -16,7 +17,7 @@ router.get('/pages', adminAuth, async (req, res) => { }); // Get a single CMS page -router.get('/pages/:slug', adminAuth, async (req, res) => { +router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, res) => { try { const { slug } = req.params; const page = await db('cms_pages').where('slug', slug).first(); @@ -33,7 +34,7 @@ router.get('/pages/:slug', adminAuth, async (req, res) => { }); // Update a CMS page -router.put('/pages/:slug', adminAuth, [ +router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ body('title_en').optional().isString(), body('title_de').optional().isString(), body('content_en').optional().isString(), diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js index c160dc31..e03ff0e3 100644 --- a/backend/src/routes/adminCategories.js +++ b/backend/src/routes/adminCategories.js @@ -3,10 +3,11 @@ const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const router = express.Router(); // Get all global categories -router.get('/global', adminAuth, async (req, res) => { +router.get('/global', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const categories = await db('photo_categories') .where('is_global', formatBoolean(true)) @@ -20,7 +21,7 @@ router.get('/global', adminAuth, async (req, res) => { }); // Get categories for a specific event (global + event-specific) -router.get('/event/:eventId', adminAuth, async (req, res) => { +router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const { eventId } = req.params; @@ -40,7 +41,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => { }); // Create a new category -router.post('/', adminAuth, [ +router.post('/', adminAuth, requirePermission('settings.edit'), [ body('name').notEmpty().withMessage('Category name is required'), body('slug').optional(), body('is_global').optional().isBoolean(), @@ -104,7 +105,7 @@ router.post('/', adminAuth, [ }); // Update a category -router.put('/:id', adminAuth, [ +router.put('/:id', adminAuth, requirePermission('settings.edit'), [ body('name').notEmpty().withMessage('Category name is required') ], async (req, res) => { try { @@ -149,7 +150,7 @@ router.put('/:id', adminAuth, [ }); // Delete a category -router.delete('/:id', adminAuth, async (req, res) => { +router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const { id } = req.params; diff --git a/backend/src/routes/adminCssTemplates.js b/backend/src/routes/adminCssTemplates.js index 38bd2ba4..b3e4ef54 100644 --- a/backend/src/routes/adminCssTemplates.js +++ b/backend/src/routes/adminCssTemplates.js @@ -8,6 +8,7 @@ const router = express.Router(); const { body, param, validationResult } = require('express-validator'); const { db, withRetry } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer'); const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates'); @@ -15,7 +16,7 @@ const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_temp * GET /admin/css-templates * Get all CSS templates */ -router.get('/', adminAuth, async (req, res) => { +router.get('/', adminAuth, requirePermission('branding.view'), async (req, res) => { try { const templates = await withRetry(() => db('css_templates').orderBy('slot_number') @@ -31,7 +32,7 @@ router.get('/', adminAuth, async (req, res) => { * GET /admin/css-templates/enabled * Get only enabled templates (for event form dropdown) */ -router.get('/enabled', adminAuth, async (req, res) => { +router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req, res) => { try { const templates = await withRetry(() => db('css_templates') @@ -50,7 +51,7 @@ router.get('/enabled', adminAuth, async (req, res) => { * GET /admin/css-templates/:slotNumber * Get a specific template by slot number */ -router.get('/:slotNumber', adminAuth, [ +router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [ param('slotNumber').isInt({ min: 1, max: 3 }) ], async (req, res) => { try { @@ -81,7 +82,7 @@ router.get('/:slotNumber', adminAuth, [ * PUT /admin/css-templates/:slotNumber * Update a template */ -router.put('/:slotNumber', adminAuth, [ +router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [ param('slotNumber').isInt({ min: 1, max: 3 }), body('name').optional().isString().isLength({ max: 50 }), body('css_content').optional().isString(), @@ -158,7 +159,7 @@ router.put('/:slotNumber', adminAuth, [ * POST /admin/css-templates/:slotNumber/reset * Reset template to default (only for slot 1) */ -router.post('/:slotNumber/reset', adminAuth, [ +router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'), [ param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default') ], async (req, res) => { try { diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index 73fea443..f66f4363 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -1,12 +1,13 @@ const express = require('express'); const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); const { formatBoolean } = require('../utils/dbCompat'); const router = express.Router(); // Get dashboard statistics -router.get('/stats', adminAuth, async (req, res) => { +router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => { try { // Get active events count const activeEvents = await db('events') @@ -106,7 +107,7 @@ router.get('/stats', adminAuth, async (req, res) => { }); // Get recent activity -router.get('/activity', adminAuth, async (req, res) => { +router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => { try { const limit = parseInt(req.query.limit) || 10; @@ -144,7 +145,7 @@ router.get('/activity', adminAuth, async (req, res) => { }); // Get system health status -router.get('/health', adminAuth, async (req, res) => { +router.get('/health', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const os = require('os'); @@ -216,7 +217,7 @@ router.get('/health', adminAuth, async (req, res) => { }); // Get analytics data for charts -router.get('/analytics', adminAuth, async (req, res) => { +router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => { try { const days = sanitizeDays(req.query.days || 7); diff --git a/backend/src/routes/adminDatabaseBackup.js b/backend/src/routes/adminDatabaseBackup.js index 5197005d..cd649886 100644 --- a/backend/src/routes/adminDatabaseBackup.js +++ b/backend/src/routes/adminDatabaseBackup.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { databaseBackupService } = require('../services/databaseBackup'); const { db } = require('../database/db'); const logger = require('../utils/logger'); @@ -11,7 +12,7 @@ router.use(adminAuth); /** * Get database backup status and configuration */ -router.get('/status', async (req, res) => { +router.get('/status', requirePermission('backup.view'), async (req, res) => { try { // Get configuration const config = await databaseBackupService.getBackupConfig(); @@ -45,7 +46,7 @@ router.get('/status', async (req, res) => { /** * Update database backup configuration */ -router.put('/config', async (req, res) => { +router.put('/config', requirePermission('backup.create'), async (req, res) => { try { const allowedSettings = [ 'database_backup_enabled', @@ -108,7 +109,7 @@ router.put('/config', async (req, res) => { /** * Trigger manual database backup */ -router.post('/backup', async (req, res) => { +router.post('/backup', requirePermission('backup.create'), async (req, res) => { try { if (databaseBackupService.isRunning) { return res.status(409).json({ error: 'Backup already in progress' }); @@ -134,7 +135,7 @@ router.post('/backup', async (req, res) => { /** * Get current backup progress */ -router.get('/progress', async (req, res) => { +router.get('/progress', requirePermission('backup.view'), async (req, res) => { try { const progress = databaseBackupService.getProgress(); @@ -151,7 +152,7 @@ router.get('/progress', async (req, res) => { /** * Get backup history with pagination */ -router.get('/history', async (req, res) => { +router.get('/history', requirePermission('backup.view'), async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 20; @@ -183,7 +184,7 @@ router.get('/history', async (req, res) => { /** * Delete old backup files */ -router.delete('/cleanup', async (req, res) => { +router.delete('/cleanup', requirePermission('backup.delete'), async (req, res) => { try { const { retentionDays = 30 } = req.body; @@ -202,7 +203,7 @@ router.delete('/cleanup', async (req, res) => { /** * Test database backup configuration */ -router.post('/test', async (req, res) => { +router.post('/test', requirePermission('backup.create'), async (req, res) => { try { const config = await databaseBackupService.getBackupConfig(); @@ -255,7 +256,7 @@ router.post('/test', async (req, res) => { /** * Get table checksums */ -router.get('/checksums', async (req, res) => { +router.get('/checksums', requirePermission('backup.view'), async (req, res) => { try { const checksums = await databaseBackupService.getTableChecksums(); diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index deb627f8..96e5062a 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -3,10 +3,11 @@ const nodemailer = require('nodemailer'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const router = express.Router(); // Get email configuration -router.get('/config', adminAuth, async (req, res) => { +router.get('/config', adminAuth, requirePermission('email.view'), async (req, res) => { try { const config = await db('email_configs').first(); @@ -37,6 +38,7 @@ router.get('/config', adminAuth, async (req, res) => { // Update email configuration router.post('/config', [ adminAuth, + requirePermission('email.edit'), body('smtp_host').notEmpty().withMessage('SMTP host is required'), body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'), body('from_email').isEmail().withMessage('Invalid from email address') @@ -100,7 +102,7 @@ router.post('/config', [ }); // Test email configuration -router.post('/test', adminAuth, async (req, res) => { +router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => { try { const { test_email } = req.body; @@ -236,7 +238,7 @@ router.post('/test', adminAuth, async (req, res) => { }); // Get email templates -router.get('/templates', adminAuth, async (req, res) => { +router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => { try { const templates = await db('email_templates') .select('*') @@ -290,7 +292,7 @@ router.get('/templates', adminAuth, async (req, res) => { }); // Get single template -router.get('/templates/:key', adminAuth, async (req, res) => { +router.get('/templates/:key', adminAuth, requirePermission('email.view'), async (req, res) => { try { const template = await db('email_templates') .where('template_key', req.params.key) @@ -346,6 +348,7 @@ router.get('/templates/:key', adminAuth, async (req, res) => { // Update email template router.put('/templates/:key', [ adminAuth, + requirePermission('email.edit'), body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'), body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'), body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'), @@ -424,7 +427,7 @@ router.put('/templates/:key', [ }); // Preview email template -router.post('/templates/:key/preview', adminAuth, async (req, res) => { +router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => { try { const template = await db('email_templates') .where('template_key', req.params.key) diff --git a/backend/src/routes/adminEventRename.js b/backend/src/routes/adminEventRename.js index ab859be6..00bdbfc4 100644 --- a/backend/src/routes/adminEventRename.js +++ b/backend/src/routes/adminEventRename.js @@ -6,6 +6,7 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const eventRenameService = require('../services/eventRenameService'); const router = express.Router(); @@ -13,7 +14,7 @@ const router = express.Router(); * POST /api/admin/events/:eventId/rename * Rename an event */ -router.post('/:eventId/rename', adminAuth, [ +router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [ body('newEventName') .trim() .isLength({ min: 3, max: 100 }) @@ -58,7 +59,7 @@ router.post('/:eventId/rename', adminAuth, [ * POST /api/admin/events/:eventId/validate-rename * Validate a potential rename without executing it */ -router.post('/:eventId/validate-rename', adminAuth, [ +router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [ body('newEventName') .trim() .isLength({ min: 3, max: 100 }) diff --git a/backend/src/routes/adminEvents-enhanced.js b/backend/src/routes/adminEvents-enhanced.js index f6ad3244..73f528e7 100644 --- a/backend/src/routes/adminEvents-enhanced.js +++ b/backend/src/routes/adminEvents-enhanced.js @@ -3,9 +3,10 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { buildShareLinkVariants } = require('../services/shareLinkService'); +const { requirePermission } = require('../middleware/permissions'); // Enhanced event creation with password validation -router.post('/', adminAuth, [ +router.post('/', adminAuth, requirePermission('events.create'), [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty().trim(), body('event_date').isDate(), diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 23b55341..beae3989 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -3,6 +3,7 @@ const { body, query, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const router = express.Router(); const bcrypt = require('bcrypt'); const crypto = require('crypto'); @@ -102,7 +103,7 @@ const hasCustomerContactColumns = async () => { }; // Create new event -router.post('/', adminAuth, [ +router.post('/', adminAuth, requirePermission('events.create'), [ body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_name').notEmpty().trim(), body('event_date').isDate(), @@ -296,6 +297,7 @@ router.post('/', adminAuth, [ share_token: shareToken, expires_at: expires_at.toISOString(), created_at: new Date().toISOString(), + created_by: req.admin.id, allow_user_uploads, upload_category_id, allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), @@ -375,7 +377,7 @@ router.post('/', adminAuth, [ }); // Get all events with pagination and filters -router.get('/', adminAuth, async (req, res) => { +router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 20; @@ -387,7 +389,12 @@ router.get('/', adminAuth, async (req, res) => { // Build query let query = db('events'); - + + // Editor role can only see their own events + if (req.admin.roleName === 'editor') { + query = query.where('created_by', req.admin.id); + } + // Apply search filter if (search) { const escapedSearch = escapeLikePattern(search); @@ -465,13 +472,18 @@ router.get('/', adminAuth, async (req, res) => { }); // Get single event details -router.get('/:id', adminAuth, async (req, res) => { +router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => { try { const { id } = req.params; - - const event = await db('events') - .where('id', id) - .first(); + + let query = db('events').where('id', id); + + // Editor role can only see their own events + if (req.admin.roleName === 'editor') { + query = query.where('created_by', req.admin.id); + } + + const event = await query.first(); if (!event) { return res.status(404).json({ error: 'Event not found' }); @@ -525,7 +537,7 @@ router.get('/:id', adminAuth, async (req, res) => { }); // Update event -router.put('/:id', adminAuth, [ +router.put('/:id', adminAuth, requirePermission('events.edit'), [ body('event_name').optional().trim().notEmpty(), body('admin_email').optional().isEmail(), body('is_active').optional().isBoolean(), @@ -659,7 +671,12 @@ router.put('/:id', adminAuth, [ }); // Check if event exists - const event = await db('events').where('id', id).first(); + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); if (!event) { return res.status(404).json({ error: 'Event not found' }); } @@ -696,7 +713,7 @@ router.put('/:id', adminAuth, [ }); // Delete event -router.delete('/:id', adminAuth, async (req, res) => { +router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => { try { const { id } = req.params; @@ -777,11 +794,16 @@ router.delete('/:id', adminAuth, async (req, res) => { }); // Toggle event status -router.post('/:id/toggle-status', adminAuth, async (req, res) => { +router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => { try { const { id } = req.params; - const event = await db('events').where('id', id).first(); + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); if (!event) { return res.status(404).json({ error: 'Event not found' }); } @@ -812,12 +834,17 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => { }); // Reset event password -router.post('/:id/reset-password', adminAuth, async (req, res) => { +router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => { try { const { id } = req.params; const { sendEmail = true } = req.body; - const event = await db('events').where('id', id).first(); + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); if (!event) { return res.status(404).json({ error: 'Event not found' }); } @@ -874,15 +901,18 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => { }); // Resend creation email -router.post('/:id/resend-email', adminAuth, async (req, res) => { +router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => { try { const { id } = req.params; - + // Get event details - const event = await db('events') - .where('id', id) - .first(); - + let eventQuery = db('events').where('id', id); + // Editor role can only edit their own events + if (req.admin.roleName === 'editor') { + eventQuery = eventQuery.where('created_by', req.admin.id); + } + const event = await eventQuery.first(); + if (!event) { return res.status(404).json({ error: 'Event not found' }); } @@ -954,7 +984,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => { }); // Archive event -router.post('/:id/archive', adminAuth, async (req, res) => { +router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => { try { const { id } = req.params; @@ -985,7 +1015,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => { }); // Bulk archive events -router.post('/bulk-archive', adminAuth, [ +router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [ body('eventIds').isArray().withMessage('eventIds must be an array'), body('eventIds.*').isInt().withMessage('Each eventId must be an integer') ], async (req, res) => { diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js index ed1183a5..f5b502d9 100644 --- a/backend/src/routes/adminExternalMedia.js +++ b/backend/src/routes/adminExternalMedia.js @@ -2,6 +2,7 @@ const express = require('express'); const path = require('path'); const fs = require('fs').promises; const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); const { db, logActivity } = require('../database/db'); const logger = require('../utils/logger'); @@ -9,7 +10,7 @@ const logger = require('../utils/logger'); const router = express.Router(); // GET /api/admin/external-media/list?path=relative/dir -router.get('/list', adminAuth, async (req, res) => { +router.get('/list', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const relPath = (req.query.path || '').replace(/^\/+/, ''); const result = await list(relPath); @@ -45,7 +46,7 @@ async function walkDir(dir, baseDir) { // POST /api/admin/events/:id/import-external // Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } } -router.post('/events/:id/import-external', adminAuth, async (req, res) => { +router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => { try { const eventId = parseInt(req.params.id); const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {}; diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js index 02d9c01f..0a38c1a2 100644 --- a/backend/src/routes/adminFeedback.js +++ b/backend/src/routes/adminFeedback.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const feedbackService = require('../services/feedbackService'); const feedbackModeration = require('../services/feedbackModeration'); const { db, logActivity } = require('../database/db'); @@ -13,8 +14,9 @@ const { } = require('../utils/feedbackValidation'); // Get event feedback settings -router.get('/events/:eventId/feedback-settings', +router.get('/events/:eventId/feedback-settings', adminAuth, + requirePermission('events.view'), validateEventId, checkValidation, async (req, res) => { @@ -39,6 +41,7 @@ router.get('/events/:eventId/feedback-settings', // Update event feedback settings router.put('/events/:eventId/feedback-settings', adminAuth, + requirePermission('events.edit'), validateEventId, validateFeedbackSettings, checkValidation, @@ -75,6 +78,7 @@ router.put('/events/:eventId/feedback-settings', // Get feedback for an event (with filters) router.get('/events/:eventId/feedback', adminAuth, + requirePermission('events.view'), validateEventId, checkValidation, async (req, res) => { @@ -159,6 +163,7 @@ router.get('/events/:eventId/feedback', // Moderate feedback (approve/hide/reject) router.put('/feedback/:feedbackId/:action', adminAuth, + requirePermission('events.edit'), async (req, res) => { try { const { feedbackId, action } = req.params; @@ -180,6 +185,7 @@ router.put('/feedback/:feedbackId/:action', // Delete feedback router.delete('/feedback/:feedbackId', adminAuth, + requirePermission('events.delete'), async (req, res) => { try { const { feedbackId } = req.params; @@ -197,6 +203,7 @@ router.delete('/feedback/:feedbackId', // Get feedback analytics for an event router.get('/events/:eventId/feedback-analytics', adminAuth, + requirePermission('events.view'), validateEventId, checkValidation, async (req, res) => { @@ -296,6 +303,7 @@ router.get('/events/:eventId/feedback-analytics', // Export feedback data router.get('/events/:eventId/feedback/export', adminAuth, + requirePermission('events.view'), validateEventId, checkValidation, async (req, res) => { @@ -324,6 +332,7 @@ router.get('/events/:eventId/feedback/export', // Get pending moderation items (across all events) router.get('/feedback/pending-moderation', adminAuth, + requirePermission('events.view'), async (req, res) => { try { const pending = await feedbackService.getPendingModeration(); @@ -338,6 +347,7 @@ router.get('/feedback/pending-moderation', // Word filter management router.get('/word-filters', adminAuth, + requirePermission('settings.view'), async (req, res) => { try { const filters = await feedbackModeration.getAllWordFilters(); @@ -351,6 +361,7 @@ router.get('/word-filters', router.post('/word-filters', adminAuth, + requirePermission('settings.edit'), validateWordFilter, checkValidation, async (req, res) => { @@ -378,6 +389,7 @@ router.post('/word-filters', router.put('/word-filters/:id', adminAuth, + requirePermission('settings.edit'), async (req, res) => { try { const { id } = req.params; @@ -395,6 +407,7 @@ router.put('/word-filters/:id', router.delete('/word-filters/:id', adminAuth, + requirePermission('settings.edit'), async (req, res) => { try { const { id } = req.params; diff --git a/backend/src/routes/adminImageSecurity.js b/backend/src/routes/adminImageSecurity.js index b0d58564..8576afac 100644 --- a/backend/src/routes/adminImageSecurity.js +++ b/backend/src/routes/adminImageSecurity.js @@ -1,6 +1,7 @@ const express = require('express'); const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const secureImageMiddleware = require('../middleware/secureImageMiddleware'); const logger = require('../utils/logger'); @@ -9,7 +10,7 @@ const router = express.Router(); /** * Get image security settings */ -router.get('/settings', adminAuth, async (req, res) => { +router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const settings = await db('app_settings') .whereIn('setting_key', [ @@ -46,7 +47,7 @@ router.get('/settings', adminAuth, async (req, res) => { /** * Update image security settings */ -router.put('/settings', adminAuth, async (req, res) => { +router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const updates = req.body; @@ -97,7 +98,7 @@ router.put('/settings', adminAuth, async (req, res) => { /** * Get security monitoring dashboard data */ -router.get('/dashboard', adminAuth, async (req, res) => { +router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const { timeframe = '24h' } = req.query; @@ -202,7 +203,7 @@ router.get('/dashboard', adminAuth, async (req, res) => { /** * Get detailed security logs */ -router.get('/logs', adminAuth, async (req, res) => { +router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const { page = 1, @@ -271,7 +272,7 @@ router.get('/logs', adminAuth, async (req, res) => { /** * Get image access logs for a specific event */ -router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => { +router.get('/events/:eventId/access-logs', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const { eventId } = req.params; const { page = 1, limit = 50 } = req.query; @@ -321,7 +322,7 @@ router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => { /** * Block/unblock suspicious IPs */ -router.post('/block-ip', adminAuth, async (req, res) => { +router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const { ip, action = 'block' } = req.body; @@ -367,7 +368,7 @@ router.post('/block-ip', adminAuth, async (req, res) => { /** * Clear security logs older than specified time */ -router.delete('/logs/cleanup', adminAuth, async (req, res) => { +router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const { olderThan = '30d' } = req.body; @@ -424,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, async (req, res) => { /** * Export security data for analysis */ -router.get('/export', adminAuth, async (req, res) => { +router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const { format = 'json', timeframe = '7d' } = req.query; diff --git a/backend/src/routes/adminNotifications.js b/backend/src/routes/adminNotifications.js index 46f310d9..bd87a780 100644 --- a/backend/src/routes/adminNotifications.js +++ b/backend/src/routes/adminNotifications.js @@ -1,10 +1,11 @@ const express = require('express'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const router = express.Router(); // Get notifications (unread activity logs) -router.get('/', adminAuth, async (req, res) => { +router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const { limit = 20, includeRead = false } = req.query; @@ -64,7 +65,7 @@ router.get('/', adminAuth, async (req, res) => { }); // Mark notification as read -router.put('/:id/read', adminAuth, async (req, res) => { +router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const { id } = req.params; @@ -82,7 +83,7 @@ router.put('/:id/read', adminAuth, async (req, res) => { }); // Mark all notifications as read -router.put('/read-all', adminAuth, async (req, res) => { +router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { await db('activity_logs') .whereNull('read_at') @@ -98,7 +99,7 @@ router.put('/read-all', adminAuth, async (req, res) => { }); // Delete old notifications (older than 30 days and read) -router.delete('/clear-old', adminAuth, async (req, res) => { +router.delete('/clear-old', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { // Use database-agnostic date calculation const thirtyDaysAgo = new Date(); diff --git a/backend/src/routes/adminPhotoExport.js b/backend/src/routes/adminPhotoExport.js index 050684d2..cd723961 100644 --- a/backend/src/routes/adminPhotoExport.js +++ b/backend/src/routes/adminPhotoExport.js @@ -8,6 +8,7 @@ const router = express.Router(); const { body, query, validationResult } = require('express-validator'); const { db, withRetry } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder'); const { PhotoExportService } = require('../services/photoExportService'); @@ -17,7 +18,7 @@ const exportService = new PhotoExportService(); * GET /admin/photos/:eventId/filtered * Get filtered photos with pagination */ -router.get('/:eventId/filtered', adminAuth, [ +router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [ query('min_rating').optional().isFloat({ min: 0, max: 5 }), query('max_rating').optional().isFloat({ min: 0, max: 5 }), query('has_likes').optional().isBoolean(), @@ -131,7 +132,7 @@ router.get('/:eventId/filtered', adminAuth, [ * GET /admin/photos/:eventId/filter-summary * Get just the summary counts for filter UI */ -router.get('/:eventId/filter-summary', adminAuth, async (req, res) => { +router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const eventId = parseInt(req.params.eventId); @@ -153,7 +154,7 @@ router.get('/:eventId/filter-summary', adminAuth, async (req, res) => { * POST /admin/photos/:eventId/export * Export selected or filtered photos */ -router.post('/:eventId/export', adminAuth, [ +router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), [ body('photo_ids').optional().isArray(), body('photo_ids.*').optional().isInt(), body('filter').optional().isObject(), @@ -213,7 +214,7 @@ router.post('/:eventId/export', adminAuth, [ * GET /admin/photos/export-formats * Get available export format options */ -router.get('/export-formats', adminAuth, (req, res) => { +router.get('/export-formats', adminAuth, requirePermission('photos.view'), (req, res) => { res.json({ success: true, data: PhotoExportService.getFormatOptions() diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 84a83209..02549af2 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -4,6 +4,7 @@ const path = require('path'); const fs = require('fs').promises; const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor'); const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { escapeLikePattern } = require('../utils/sqlSecurity'); @@ -109,7 +110,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default // Upload photos for an event // Max file count is configurable via general settings -router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout +router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout let maxFilesPerUpload; try { maxFilesPerUpload = await getMaxFilesPerUpload(); @@ -420,7 +421,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re }); // Delete a photo -router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { +router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => { try { const { eventId, photoId } = req.params; @@ -477,7 +478,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { }); // Update a photo (e.g., change category) -router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => { +router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => { try { const { eventId, photoId } = req.params; const { category_id } = req.body; @@ -525,7 +526,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => { }); // Bulk delete photos -router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => { +router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => { try { const { eventId } = req.params; const { photoIds } = req.body; @@ -593,7 +594,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => { }); // Bulk update photos -router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => { +router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => { try { const { eventId } = req.params; const { photoIds, updates } = req.body; @@ -651,7 +652,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => { }); // Download a photo -router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => { +router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => { try { const { eventId, photoId } = req.params; @@ -683,7 +684,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => }); // Get all photos for an event -router.get('/:eventId/photos', adminAuth, async (req, res) => { +router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const { eventId } = req.params; const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; @@ -767,7 +768,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { }); // Serve photo with admin authentication -router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => { +router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const { eventId, photoId } = req.params; @@ -804,7 +805,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => { }); // Serve thumbnail with admin authentication -router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => { +router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const { eventId, photoId } = req.params; @@ -844,7 +845,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => { }); // Debug endpoint to check photo existence -router.get('/:eventId/debug', adminAuth, async (req, res) => { +router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const { eventId } = req.params; @@ -870,7 +871,7 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => { // ============================================ // Initialize a chunked upload -router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => { +router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => { try { const { eventId } = req.params; const { filename, fileSize, mimeType, totalChunks } = req.body; @@ -908,7 +909,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => { }); // Upload a chunk -router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, async (req, res) => { +router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => { try { const { uploadId, chunkIndex } = req.params; @@ -929,7 +930,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, a }); // Complete chunked upload and process the file -router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req, res) => { +router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => { try { const { eventId, uploadId } = req.params; const { category_id } = req.body; @@ -971,7 +972,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req }); // Get upload status -router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, res) => { +router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const { uploadId } = req.params; @@ -989,7 +990,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, r }); // Abort chunked upload -router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, async (req, res) => { +router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => { try { const { uploadId } = req.params; diff --git a/backend/src/routes/adminRestore.js b/backend/src/routes/adminRestore.js index b1fe3d6b..f6c53c4e 100644 --- a/backend/src/routes/adminRestore.js +++ b/backend/src/routes/adminRestore.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const { restoreService } = require('../services/restoreService'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { body, query, validationResult } = require('express-validator'); const logger = require('../utils/logger'); const { db } = require('../database/db'); @@ -16,10 +17,36 @@ const fs = require('fs').promises; // Apply admin authentication to all routes router.use(adminAuth); +/** + * Transform frontend S3 config to backend format + * Frontend sends: s3Endpoint, s3Bucket, s3AccessKey, s3SecretKey, s3Region + * Backend expects: endpoint, bucket, accessKeyId, secretAccessKey, region + */ +function transformS3Config(body) { + if (body.s3Config) { + // Already in correct format + return body.s3Config; + } + + // Check if frontend sent flat S3 config fields + if (body.s3Endpoint || body.s3Bucket || body.s3AccessKey || body.s3SecretKey) { + return { + endpoint: body.s3Endpoint, + bucket: body.s3Bucket, + accessKeyId: body.s3AccessKey, + secretAccessKey: body.s3SecretKey, + region: body.s3Region || 'us-east-1', + forcePathStyle: body.s3ForcePathStyle !== false + }; + } + + return null; +} + /** * Get restore service status and history */ -router.get('/status', async (req, res) => { +router.get('/status', requirePermission('backup.view'), async (req, res) => { try { const limit = parseInt(req.query.limit) || 10; const history = await restoreService.getRestoreHistory(limit); @@ -47,7 +74,7 @@ router.get('/status', async (req, res) => { /** * Validate restore request */ -router.post('/validate', [ +router.post('/validate', requirePermission('backup.restore'), [ body('source').notEmpty().withMessage('Backup source is required'), body('manifestPath').notEmpty().withMessage('Manifest path is required'), body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'), @@ -63,18 +90,38 @@ router.post('/validate', [ } try { + // Transform S3 config from frontend format + const s3Config = transformS3Config(req.body); + // Perform dry run validation const result = await restoreService.restore({ - ...req.body, + source: req.body.source, + manifestPath: req.body.manifestPath, + restoreType: req.body.restoreType, + selectedItems: req.body.selectedItems, + s3Config, dryRun: true, force: false }); - + + // Transform spaceCheck to match frontend expected format + const spaceCheck = result.spaceCheck ? { + sufficient: result.spaceCheck.hasEnoughSpace, + required: result.spaceCheck.requiredBytes, + available: result.spaceCheck.availableBytes, + requiredFormatted: result.spaceCheck.requiredFormatted, + availableFormatted: result.spaceCheck.availableFormatted, + // Keep original fields for backwards compatibility + hasEnoughSpace: result.spaceCheck.hasEnoughSpace, + requiredBytes: result.spaceCheck.requiredBytes, + availableBytes: result.spaceCheck.availableBytes + } : null; + res.json({ success: true, data: { validation: result.validation, - spaceCheck: result.spaceCheck, + spaceCheck, logs: result.logs } }); @@ -82,7 +129,7 @@ router.post('/validate', [ logger.error('Restore validation failed:', error); res.status(400).json({ success: false, - error: 'Restore validation failed', + error: error.message || 'Restore validation failed', logs: restoreService.restoreLog }); } @@ -91,7 +138,7 @@ router.post('/validate', [ /** * Start restore operation */ -router.post('/start', [ +router.post('/start', requirePermission('backup.restore'), [ body('source').notEmpty().withMessage('Backup source is required'), body('manifestPath').notEmpty().withMessage('Manifest path is required'), body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'), @@ -135,19 +182,28 @@ router.post('/start', [ // Log restore attempt logger.warn('Restore operation started', { - user: req.user.email, + user: req.admin.email, ip: req.ip, restoreType: req.body.restoreType, source: req.body.source }); + // Transform S3 config from frontend format + const s3Config = transformS3Config(req.body); + // Start restore in background restoreService.restore({ - ...req.body, + source: req.body.source, + manifestPath: req.body.manifestPath, + restoreType: req.body.restoreType, + selectedItems: req.body.selectedItems, + skipPreBackup: req.body.skipPreBackup, + force: req.body.force, + s3Config, dryRun: false, operator: { type: 'manual', - userId: req.user.id, + userId: req.admin.id, ip: req.ip } }).catch(error => { @@ -160,9 +216,10 @@ router.post('/start', [ }); } catch (error) { logger.error('Failed to start restore:', error); + logger.error('Error stack:', error.stack); res.status(500).json({ success: false, - error: 'Failed to start restore operation' + error: error.message || 'Failed to start restore operation' }); } }); @@ -170,7 +227,7 @@ router.post('/start', [ /** * Get current restore progress */ -router.get('/progress', async (req, res) => { +router.get('/progress', requirePermission('backup.view'), async (req, res) => { try { const progress = restoreService.getProgress(); const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries @@ -195,7 +252,7 @@ router.get('/progress', async (req, res) => { /** * Get restore run details */ -router.get('/run/:id', async (req, res) => { +router.get('/run/:id', requirePermission('backup.view'), async (req, res) => { try { const run = await db('restore_runs') .where('id', req.params.id) @@ -250,7 +307,7 @@ router.get('/run/:id', async (req, res) => { /** * Get restore run report */ -router.get('/run/:id/report', async (req, res) => { +router.get('/run/:id/report', requirePermission('backup.view'), async (req, res) => { try { const run = await db('restore_runs') .where('id', req.params.id) @@ -289,7 +346,7 @@ router.get('/run/:id/report', async (req, res) => { /** * List available backups for restore */ -router.get('/available-backups', async (req, res) => { +router.get('/available-backups', requirePermission('backup.view'), async (req, res) => { try { const backups = []; @@ -349,10 +406,85 @@ router.get('/available-backups', async (req, res) => { } }); +/** + * List backups for restore (POST version for frontend compatibility) + * Accepts source type in request body + */ +router.post('/list-backups', requirePermission('backup.view'), async (req, res) => { + try { + const { source } = req.body; // 'local', 's3', or undefined for all + const backups = []; + + // Get backup configuration + const backupConfig = await getBackupConfig(); + + // Get database backups from backup_runs table + const backupRuns = await db('backup_runs') + .where('status', 'completed') + .whereNotNull('manifest_path') + .orderBy('completed_at', 'desc') + .limit(20); + + for (const run of backupRuns) { + const isS3 = run.manifest_path.startsWith('s3://'); + const backupType = isS3 ? 's3' : 'local'; + + // Filter by source if specified + if (source && source !== backupType) { + continue; + } + + backups.push({ + id: run.id, + type: backupType, + name: `Backup from ${new Date(run.completed_at).toLocaleString()}`, + path: run.manifest_path, + manifest_path: run.manifest_path, + manifestId: run.manifest_id, + manifestPath: run.manifest_path, + size: parseInt(run.total_size_bytes) || 0, + total_size: parseInt(run.total_size_bytes) || 0, + total_size_bytes: parseInt(run.total_size_bytes) || 0, + filesCount: run.files_backed_up || 0, + files_backed_up: run.files_backed_up || 0, + duration: run.duration_seconds, + duration_seconds: run.duration_seconds, + // Frontend expects snake_case date fields + created_at: run.completed_at, + completed_at: run.completed_at, + started_at: run.started_at, + // camelCase aliases + completedAt: run.completed_at, + startedAt: run.started_at, + // Backup metadata + status: run.status, + backup_type: run.backup_type, + backupType: run.backup_type, + backup_mode: run.backup_mode, + backupMode: run.backup_mode, + app_version: run.app_version, + appVersion: run.app_version + }); + } + + res.json({ + success: true, + data: backups, + source: source || 'all' + }); + } catch (error) { + logger.error('Failed to list backups for restore:', error); + res.status(500).json({ + success: false, + error: 'Failed to list backups for restore' + }); + } +}); + /** * Get restore settings */ -router.get('/settings', async (req, res) => { +router.get('/settings', requirePermission('backup.view'), async (req, res) => { try { const settings = await getRestoreSettings(); res.json({ @@ -371,7 +503,7 @@ router.get('/settings', async (req, res) => { /** * Update restore settings */ -router.put('/settings', [ +router.put('/settings', requirePermission('backup.restore'), [ body('restore_allow_force').optional().isBoolean(), body('restore_require_pre_backup').optional().isBoolean(), body('restore_max_file_size_mb').optional().isInt({ min: 1 }), diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 01ea906a..35eb5e57 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -6,6 +6,7 @@ const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { clearMaintenanceCache } = require('../middleware/maintenance'); const { clearSettingsCache } = require('../services/rateLimitService'); const { @@ -92,7 +93,7 @@ const faviconUpload = multer({ }); // Get all settings -router.get('/', adminAuth, async (req, res) => { +router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const settings = await db('app_settings').select('*'); @@ -120,7 +121,7 @@ router.get('/', adminAuth, async (req, res) => { }); // Get settings by type -router.get('/:type', adminAuth, async (req, res) => { +router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const { type } = req.params; const settings = await db('app_settings') @@ -151,7 +152,7 @@ router.get('/:type', adminAuth, async (req, res) => { }); // Get password complexity settings for frontend -router.get('/password/complexity', adminAuth, async (req, res) => { +router.get('/password/complexity', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation'); @@ -172,7 +173,7 @@ router.get('/password/complexity', adminAuth, async (req, res) => { }); // Update branding settings -router.put('/branding', adminAuth, async (req, res) => { +router.put('/branding', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const { company_name, @@ -313,7 +314,7 @@ router.put('/branding', adminAuth, async (req, res) => { }); // Upload logo -router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => { +router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.single('logo'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No logo file uploaded' }); @@ -375,7 +376,7 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => { }); // Upload watermark logo -router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => { +router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); @@ -437,7 +438,7 @@ router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo' }); // Update theme settings -router.put('/theme', adminAuth, async (req, res) => { +router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const themeSettings = req.body; @@ -474,7 +475,7 @@ router.put('/theme', adminAuth, async (req, res) => { }); // Update general settings -router.put('/general', adminAuth, async (req, res) => { +router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const settings = { ...req.body }; let uploadLimitTouched = false; @@ -573,7 +574,7 @@ router.put('/general', adminAuth, async (req, res) => { }); // Update security settings -router.put('/security', adminAuth, async (req, res) => { +router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const settings = req.body; @@ -612,7 +613,7 @@ router.put('/security', adminAuth, async (req, res) => { }); // Update analytics settings -router.put('/analytics', adminAuth, async (req, res) => { +router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const settings = req.body; @@ -649,7 +650,7 @@ router.put('/analytics', adminAuth, async (req, res) => { }); // Get storage info -router.get('/storage/info', adminAuth, async (req, res) => { +router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => { try { // Get total storage used const totalStorage = await db('photos') @@ -877,7 +878,7 @@ router.get('/storage/info', adminAuth, async (req, res) => { }); // Upload favicon endpoint -router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => { +router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUpload.single('favicon'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: 'No favicon file provided' }); @@ -915,7 +916,7 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, }); // Update rate limit settings -router.put('/security/rate-limit', adminAuth, [ +router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit'), [ body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'), body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'), body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'), @@ -979,7 +980,7 @@ router.put('/security/rate-limit', adminAuth, [ }); // Get default public site template -router.get('/public-site/default', adminAuth, async (req, res) => { +router.get('/public-site/default', adminAuth, requirePermission('settings.view'), async (req, res) => { try { const defaults = await getDefaultPublicSitePayload(); @@ -1000,7 +1001,7 @@ router.get('/public-site/default', adminAuth, async (req, res) => { }); // Reset public site template to defaults -router.post('/public-site/reset', adminAuth, async (req, res) => { +router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'), async (req, res) => { try { const entries = [ { diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index ac6e2d66..491bbb41 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -1,6 +1,7 @@ const express = require('express'); const { db, withRetry } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const fs = require('fs').promises; const path = require('path'); const os = require('os'); @@ -9,7 +10,7 @@ const logger = require('../utils/logger'); const router = express.Router(); // Get system version -router.get('/version', adminAuth, async (req, res) => { +router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => { try { // Read backend version from package.json let backendVersion = '1.0.0'; @@ -35,7 +36,7 @@ router.get('/version', adminAuth, async (req, res) => { }); // Get comprehensive system status -router.get('/status', adminAuth, async (req, res) => { +router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => { try { // Database size - check if PostgreSQL or SQLite let dbSize = 0; @@ -170,7 +171,7 @@ router.get('/status', adminAuth, async (req, res) => { }); // Get database statistics -router.get('/database', adminAuth, async (req, res) => { +router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => { try { // Get table info const tables = [ diff --git a/backend/src/routes/adminThumbnails.js b/backend/src/routes/adminThumbnails.js index 49a64ae9..e80b333e 100644 --- a/backend/src/routes/adminThumbnails.js +++ b/backend/src/routes/adminThumbnails.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const { db } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); const { generateThumbnail } = require('../services/imageProcessor'); const path = require('path'); const fs = require('fs').promises; @@ -10,7 +11,7 @@ const logger = require('../utils/logger'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); // Get thumbnail settings -router.get('/settings', adminAuth, async (req, res) => { +router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const settings = await db('app_settings') .whereIn('key', [ @@ -42,7 +43,7 @@ router.get('/settings', adminAuth, async (req, res) => { }); // Update thumbnail settings -router.put('/settings', adminAuth, async (req, res) => { +router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => { try { const { width, height, fit, quality, format } = req.body; @@ -91,7 +92,7 @@ router.put('/settings', adminAuth, async (req, res) => { }); // Regenerate all thumbnails with new settings -router.post('/regenerate', adminAuth, async (req, res) => { +router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (req, res) => { try { const { eventId } = req.body; // Optional: regenerate for specific event only @@ -164,7 +165,7 @@ router.post('/regenerate', adminAuth, async (req, res) => { }); // Get regeneration status -router.get('/regenerate/status', adminAuth, async (req, res) => { +router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => { try { // Count photos with and without thumbnails const totalPhotos = await db('photos').count('id as count').first(); diff --git a/backend/src/routes/adminUsers.js b/backend/src/routes/adminUsers.js new file mode 100644 index 00000000..82d09abc --- /dev/null +++ b/backend/src/routes/adminUsers.js @@ -0,0 +1,194 @@ +/** + * Admin Users Routes + * Handles user management, roles, and invitations + */ + +const express = require('express'); +const { body, param } = require('express-validator'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission, requireSuperAdmin, getUserPermissions } = require('../middleware/permissions'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const userManagementService = require('../services/userManagementService'); +const router = express.Router(); + +/** + * Transform user object from snake_case (DB) to camelCase (API) + */ +function transformUser(user) { + return { + id: user.id, + username: user.username, + email: user.email, + isActive: user.is_active, + lastLogin: user.last_login, + lastLoginIp: user.last_login_ip, + createdAt: user.created_at, + updatedAt: user.updated_at, + roleId: user.role_id, + roleName: user.role_name, + roleDisplayName: user.role_display_name, + createdByUsername: user.created_by_username + }; +} + +/** + * Transform role object from snake_case (DB) to camelCase (API) + */ +function transformRole(role) { + return { + id: role.id, + name: role.name, + displayName: role.display_name, + description: role.description, + isSystem: role.is_system, + priority: role.priority + }; +} + +/** + * GET /me/permissions + * Get current user's permissions + */ +router.get('/me/permissions', adminAuth, handleAsync(async (req, res) => { + const permissions = await getUserPermissions(req.admin.id); + res.json(permissions); +})); + +/** + * GET / + * List all admin users + * Requires: users.view permission + */ +router.get('/', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => { + const users = await userManagementService.getAllAdminUsers(); + res.json({ users: users.map(transformUser) }); +})); + +/** + * GET /roles + * List all roles + * Requires: users.view permission + */ +router.get('/roles', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => { + const roles = await userManagementService.getAllRoles(); + res.json({ roles: roles.map(transformRole) }); +})); + +/** + * GET /invitations + * List pending invitations + * Requires: users.view permission + */ +router.get('/invitations', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => { + const invitations = await userManagementService.getPendingInvitations(); + res.json({ invitations }); +})); + +/** + * POST /invite + * Create invitation + * Requires: users.create permission + */ +router.post('/invite', [ + adminAuth, + requirePermission('users.create'), + body('email').isEmail().normalizeEmail().withMessage('Valid email is required'), + body('role_id').isInt({ min: 1 }).withMessage('Role ID is required') +], handleAsync(async (req, res) => { + validateRequest(req); + + const invitation = await userManagementService.createInvitation({ + email: req.body.email, + roleId: req.body.role_id, + invitedById: req.admin.id + }); + + successResponse(res, { invitation }, 201); +})); + +/** + * DELETE /invitations/:id + * Cancel invitation + * Requires: users.create permission + */ +router.delete('/invitations/:id', [ + adminAuth, + requirePermission('users.create'), + param('id').isInt({ min: 1 }).withMessage('Valid invitation ID is required') +], handleAsync(async (req, res) => { + validateRequest(req); + await userManagementService.cancelInvitation(parseInt(req.params.id), req.admin.id); + successResponse(res, { message: 'Invitation cancelled' }); +})); + +/** + * GET /:id + * Get single user + * Requires: users.view permission + */ +router.get('/:id', [ + adminAuth, + requirePermission('users.view'), + param('id').isInt({ min: 1 }).withMessage('Valid user ID is required') +], handleAsync(async (req, res) => { + validateRequest(req); + const user = await userManagementService.getAdminUserById(parseInt(req.params.id)); + res.json({ user: transformUser(user) }); +})); + +/** + * PUT /:id + * Update user + * Requires: users.edit permission + */ +router.put('/:id', [ + adminAuth, + requirePermission('users.edit'), + param('id').isInt({ min: 1 }).withMessage('Valid user ID is required'), + body('username').optional().trim().isLength({ min: 3, max: 50 }).withMessage('Username must be 3-50 characters'), + body('email').optional().isEmail().normalizeEmail().withMessage('Valid email is required'), + body('role_id').optional().isInt({ min: 1 }).withMessage('Valid role ID is required'), + body('is_active').optional().isBoolean().withMessage('is_active must be boolean') +], handleAsync(async (req, res) => { + validateRequest(req); + + const user = await userManagementService.updateAdminUser( + parseInt(req.params.id), + req.body, + req.admin.id + ); + + successResponse(res, { user: transformUser(user), message: 'User updated successfully' }); +})); + +/** + * POST /:id/deactivate + * Deactivate user + * Requires: users.delete permission + */ +router.post('/:id/deactivate', [ + adminAuth, + requirePermission('users.delete'), + param('id').isInt({ min: 1 }).withMessage('Valid user ID is required') +], handleAsync(async (req, res) => { + validateRequest(req); + await userManagementService.deactivateAdminUser(parseInt(req.params.id), req.admin.id); + successResponse(res, { message: 'User deactivated successfully' }); +})); + +/** + * POST /:id/reset-password + * Reset user password + * Requires: super_admin role + */ +router.post('/:id/reset-password', [ + adminAuth, + requireSuperAdmin(), + param('id').isInt({ min: 1 }).withMessage('Valid user ID is required') +], handleAsync(async (req, res) => { + validateRequest(req); + const result = await userManagementService.resetAdminPassword(parseInt(req.params.id), req.admin.id); + successResponse(res, { message: 'Password reset email sent', ...result }); +})); + +module.exports = router; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 51754d0d..ca1b42c8 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -70,52 +70,65 @@ router.post('/admin/login', [ logger.warn('Suspicious login pattern detected', { username, ipAddress }); } + // Fetch admin with role information const admin = await db('admin_users') - .where({ username }) - .orWhere({ email: username }) + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.username', username) + .orWhere('admin_users.email', username) + .select( + 'admin_users.*', + 'roles.name as role_name', + 'roles.display_name as role_display_name' + ) .first(); - + // Use generic error to prevent user enumeration if (!admin || !await bcrypt.compare(password, admin.password_hash)) { await trackFailedAttempt(username, ipAddress, userAgent); return res.status(401).json({ error: getGenericAuthError() }); } - + if (!admin.is_active) { await trackFailedAttempt(username, ipAddress, userAgent); return res.status(401).json({ error: getGenericAuthError() }); } - + // Successful login await trackSuccessfulLogin(username, ipAddress, userAgent); - + // Update last login and login metadata - await db('admin_users').where('id', admin.id).update({ + await db('admin_users').where('id', admin.id).update({ last_login: new Date(), last_login_ip: ipAddress }); - - // Generate token with additional claims - const token = jwt.sign({ + + // Generate token with additional claims including role + const token = jwt.sign({ id: admin.id, username: admin.username, type: 'admin', + role: admin.role_name, // Add role to JWT ip: ipAddress, loginTime: Date.now() - }, process.env.JWT_SECRET, { + }, process.env.JWT_SECRET, { expiresIn: '24h', issuer: 'picpeak-auth' }); setAdminAuthCookie(res, token); - + + // Include role in response res.json({ token, user: { id: admin.id, username: admin.username, email: admin.email, - mustChangePassword: admin.must_change_password || false + mustChangePassword: admin.must_change_password || false, + role: admin.role_name ? { + name: admin.role_name, + displayName: admin.role_display_name + } : null } }); } catch (error) { diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index deeda9e5..d5d57447 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -728,15 +728,15 @@ async function runBackupInternal() { } const schemaVersion = await getCurrentSchemaVersion(); - const [insertedId] = await db('backup_runs').insert({ + const insertResult = await db('backup_runs').insert({ started_at: startTime, status: 'running', backup_type: 'scheduled', app_version: packageJson.version, node_version: process.version, db_schema_version: schemaVersion - }); - runId = insertedId; + }).returning('id'); + runId = insertResult[0]?.id || insertResult[0]; const files = await service.getFilesToBackup(config.backup_include_archived); logger.info(`Found ${files.length} files to check for backup`); @@ -820,11 +820,17 @@ async function runBackupInternal() { manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null, manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null, statistics: JSON.stringify({ + // Use snake_case for frontend compatibility + files_processed: result.backedUpCount, + total_size: result.backedUpSize, + total_files_checked: files.length, + average_file_size: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0, + destination: destinationType, + // Keep camelCase for backward compatibility totalFilesChecked: files.length, filesBackedUp: result.backedUpCount, totalSize: result.backedUpSize, - averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0, - destination: destinationType + averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0 }) }); @@ -927,22 +933,54 @@ async function triggerManualBackup() { async function getBackupStatus(limit = 10) { try { - const runs = await db('backup_runs') + const rawRuns = await db('backup_runs') .orderBy('started_at', 'desc') .limit(limit); + // Transform runs to add frontend-compatible field aliases + const runs = rawRuns.map(run => { + // Parse and transform statistics to snake_case for frontend compatibility + let statistics = run.statistics; + if (statistics) { + // Handle both string (SQLite) and object (PostgreSQL JSONB) types + let stats = statistics; + if (typeof statistics === 'string') { + try { + stats = JSON.parse(statistics); + } catch (e) { + stats = {}; + } + } + // Add snake_case aliases for frontend + statistics = { + ...stats, + files_processed: stats.filesBackedUp || stats.files_processed || 0, + total_size: stats.totalSize || stats.total_size || 0, + total_files_checked: stats.totalFilesChecked || stats.total_files_checked || 0, + average_file_size: stats.averageFileSize || stats.average_file_size || 0 + }; + } + + return { + ...run, + created_at: run.started_at, // Alias for frontend compatibility + statistics + }; + }); + const lastRun = runs[0]; let manifestValid = false; if (lastRun && lastRun.manifest_path) { try { - const manifest = await backupManifest.loadManifest(lastRun.manifest_path); - if (backupManifest.validateManifest) { - backupManifest.validateManifest(manifest); + // Use validateBackupManifest which handles both local and S3 paths + const result = await validateBackupManifest(lastRun.manifest_path); + manifestValid = result.valid; + if (!result.valid) { + logger.warn('Manifest validation failed:', result.error); } - manifestValid = true; } catch (error) { - logger.warn('Manifest validation failed:', error); + logger.warn('Manifest validation failed:', error.message); } } @@ -951,6 +989,7 @@ async function getBackupStatus(limit = 10) { isHealthy: Boolean(lastRun && lastRun.status === 'completed'), lastRun: lastRun ? { ...lastRun, manifestValid } : null, recentRuns: runs, + recentBackups: runs, // Alias for frontend compatibility nextScheduledRun: getNextScheduledRun() }; } catch (error) { diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 552e6a6f..85c47ae1 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -153,6 +153,9 @@ async function processTemplate(template, variables, language = 'en') { if (processedVariables.archive_date) { processedVariables.archive_date = await formatDate(processedVariables.archive_date, language); } + if (processedVariables.expires_at) { + processedVariables.expires_at = await formatDate(processedVariables.expires_at, language); + } // Format welcome message for HTML display (preserve line breaks) if (processedVariables.welcome_message) { diff --git a/backend/src/services/restoreService.js b/backend/src/services/restoreService.js index 05967f35..c50ac6e7 100644 --- a/backend/src/services/restoreService.js +++ b/backend/src/services/restoreService.js @@ -75,14 +75,15 @@ class RestoreService { this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) }); // Create restore run record - const [runId] = await db('restore_runs').insert({ + const result = await db('restore_runs').insert({ started_at: startTime, status: 'running', restore_type: options.restoreType, source: options.source, manifest_path: options.manifestPath, is_dry_run: options.dryRun || false - }); + }).returning('id'); + const runId = Array.isArray(result) ? (result[0]?.id || result[0]) : result; restoreRun = { id: runId }; @@ -105,7 +106,8 @@ class RestoreService { if (validation.warnings.length > 0) { this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings }); - if (!options.force) { + // Only block actual restores (not dry runs/validations) on warnings + if (!options.force && !options.dryRun) { throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`); } } @@ -400,29 +402,55 @@ class RestoreService { * Check available disk space */ async checkDiskSpace(manifest, options) { - const { statvfs } = require('fs'); - const statvfsAsync = promisify(statvfs); - try { const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); - const stats = await statvfsAsync(storagePath); - - const blockSize = stats.bsize || stats.f_bsize || 4096; - const availableBytes = stats.bavail * blockSize; - + // Calculate required space (with 20% buffer) let requiredBytes = 0; if (options.restoreType === 'full' || options.restoreType === 'files') { - requiredBytes = manifest.files.total_size * 1.2; + requiredBytes = (manifest.files?.total_size || 0) * 1.2; } if (options.restoreType === 'full' || options.restoreType === 'database') { - requiredBytes += (manifest.database.size || 0) * 1.2; + requiredBytes += (manifest.database?.size || 0) * 1.2; + } + + // Try to get disk space using df command (works on Linux and macOS) + let availableBytes = 0; + let diskCheckSucceeded = false; + try { + const { exec } = require('child_process'); + const execAsync = promisify(exec); + // Use root path as fallback if storage path doesn't exist yet + const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/'); + const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`); + const parsed = parseInt(stdout.trim()); + if (!isNaN(parsed) && parsed > 0) { + availableBytes = parsed * 1024; // Convert from KB to bytes + diskCheckSucceeded = true; + } + } catch (dfError) { + this.log('warn', 'Could not determine available disk space', { error: dfError.message }); + } + + // If disk check failed, return optimistic result + if (!diskCheckSucceeded) { + return { + hasEnoughSpace: true, + availableBytes: null, // null indicates unknown + requiredBytes, + availableFormatted: 'Unknown', + requiredFormatted: this.formatBytes(requiredBytes) + }; } // Add space for pre-restore backup if (!options.skipPreBackup) { - const currentUsage = await this.calculateCurrentStorageUsage(); - requiredBytes += currentUsage * 1.1; // 10% buffer for backup + try { + const currentUsage = await this.calculateCurrentStorageUsage(); + requiredBytes += currentUsage * 1.1; // 10% buffer for backup + } catch (e) { + // Ignore errors calculating current usage + } } return { @@ -434,11 +462,11 @@ class RestoreService { }; } catch (error) { - // Fallback for systems without statvfs + // Fallback for any errors this.log('warn', 'Could not check disk space', { error: error.message }); return { hasEnoughSpace: true, // Assume we have space if we can't check - availableBytes: 0, + availableBytes: null, requiredBytes: 0, availableFormatted: 'Unknown', requiredFormatted: 'Unknown' diff --git a/backend/src/services/storage/s3Storage.js b/backend/src/services/storage/s3Storage.js index fe6b3c3b..9a481093 100644 --- a/backend/src/services/storage/s3Storage.js +++ b/backend/src/services/storage/s3Storage.js @@ -76,12 +76,22 @@ class S3StorageAdapter extends stream.EventEmitter { // Add custom endpoint if provided (for S3-compatible services) if (this.config.endpoint) { - s3Config.endpoint = this.config.endpoint; - // For MinIO and other S3-compatible services - if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) { - s3Config.endpoint = `https://${this.config.endpoint}`; - } else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) { - s3Config.endpoint = `http://${this.config.endpoint}`; + let endpoint = this.config.endpoint; + + // Only add protocol if endpoint doesn't already have one + const hasProtocol = endpoint.startsWith('http://') || endpoint.startsWith('https://'); + if (!hasProtocol) { + // Add protocol based on sslEnabled setting + endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`; + } + + s3Config.endpoint = endpoint; + + // For S3-compatible services with custom endpoints, force path style + // This is required for MinIO and when using IP addresses + if (!s3Config.forcePathStyle) { + s3Config.forcePathStyle = true; + logger.info('Automatically enabling forcePathStyle for custom S3 endpoint'); } } diff --git a/backend/src/services/userManagementService.js b/backend/src/services/userManagementService.js new file mode 100644 index 00000000..69b99228 --- /dev/null +++ b/backend/src/services/userManagementService.js @@ -0,0 +1,440 @@ +/** + * User Management Service for Admin Users + * Handles invitations, user CRUD, and role management + */ + +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +const { db, logActivity } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { generateReadablePassword } = require('../utils/passwordGenerator'); +const { getBcryptRounds } = require('../utils/passwordValidation'); +const { queueEmail } = require('./emailProcessor'); +const logger = require('../utils/logger'); +const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors'); + +/** + * Create a new admin user invitation + * @param {object} params - { email, roleId, invitedById } + * @returns {Promise} Created invitation details + */ +async function createInvitation({ email, roleId, invitedById }) { + // Check if email already exists + const existingUser = await db('admin_users').where('email', email).first(); + if (existingUser) { + throw new ConflictError('User with this email already exists', 'email'); + } + + // Check for pending invitation + const pendingInvite = await db('admin_invitations') + .where('email', email) + .whereNull('accepted_at') + .where('expires_at', '>', new Date()) + .first(); + + if (pendingInvite) { + throw new ConflictError('Pending invitation already exists for this email', 'email'); + } + + // Validate role exists + const role = await db('roles').where('id', roleId).first(); + if (!role) { + throw new NotFoundError('Role', roleId); + } + + // Generate secure invitation token (64 characters hex = 32 bytes) + const token = crypto.randomBytes(32).toString('hex'); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + const [invitationId] = await db('admin_invitations').insert({ + email, + token, + role_id: roleId, + invited_by: invitedById, + expires_at: expiresAt, + created_at: new Date() + }).returning('id'); + + const id = invitationId?.id || invitationId; + + // Queue invitation email + const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005'; + await queueEmail(null, email, 'admin_invitation', { + invite_link: `${frontendUrl}/admin/accept-invite/${token}`, + role_name: role.display_name, + expires_at: expiresAt.toISOString() + }); + + await logActivity('admin_invitation_created', + { email, roleId, roleName: role.display_name }, + null, + { type: 'admin', id: invitedById, name: 'system' } + ); + + logger.info('Admin invitation created', { email, roleId, invitedById }); + + return { id, email, token, role: role.display_name, expiresAt }; +} + +/** + * Accept an invitation and create the admin user + * @param {object} params - { token, username, password } + * @returns {Promise} Created user details + */ +async function acceptInvitation({ token, username, password }) { + const invitation = await db('admin_invitations') + .where('token', token) + .whereNull('accepted_at') + .where('expires_at', '>', new Date()) + .first(); + + if (!invitation) { + throw new ValidationError('Invalid or expired invitation'); + } + + // Check username availability + const existingUsername = await db('admin_users').where('username', username).first(); + if (existingUsername) { + throw new ConflictError('Username already taken', 'username'); + } + + // Check email not taken (race condition protection) + const existingEmail = await db('admin_users').where('email', invitation.email).first(); + if (existingEmail) { + throw new ConflictError('Email already registered', 'email'); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, getBcryptRounds()); + + // Create user in transaction + const result = await db.transaction(async (trx) => { + const [userId] = await trx('admin_users').insert({ + username, + email: invitation.email, + password_hash: passwordHash, + role_id: invitation.role_id, + created_by: invitation.invited_by, + is_active: formatBoolean(true), + must_change_password: formatBoolean(false), + invite_accepted_at: new Date(), + created_at: new Date(), + updated_at: new Date() + }).returning('id'); + + const id = userId?.id || userId; + + // Mark invitation as accepted + await trx('admin_invitations') + .where('id', invitation.id) + .update({ + accepted_at: new Date(), + accepted_user_id: id + }); + + return id; + }); + + await logActivity('admin_invitation_accepted', + { userId: result, email: invitation.email }, + null, + { type: 'system', id: null, name: 'system' } + ); + + logger.info('Admin invitation accepted', { + userId: result, + email: invitation.email, + invitationId: invitation.id + }); + + return { userId: result, email: invitation.email }; +} + +/** + * Get all admin users with their roles + * @returns {Promise} + */ +async function getAllAdminUsers() { + return db('admin_users') + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .leftJoin('admin_users as creator', 'creator.id', 'admin_users.created_by') + .select( + 'admin_users.id', + 'admin_users.username', + 'admin_users.email', + 'admin_users.is_active', + 'admin_users.last_login', + 'admin_users.last_login_ip', + 'admin_users.created_at', + 'admin_users.updated_at', + 'roles.id as role_id', + 'roles.name as role_name', + 'roles.display_name as role_display_name', + 'creator.username as created_by_username' + ) + .orderBy('admin_users.created_at', 'desc'); +} + +/** + * Get single admin user by ID + * @param {number} id - User ID + * @returns {Promise} + */ +async function getAdminUserById(id) { + const user = await db('admin_users') + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .where('admin_users.id', id) + .select( + 'admin_users.id', + 'admin_users.username', + 'admin_users.email', + 'admin_users.is_active', + 'admin_users.last_login', + 'admin_users.last_login_ip', + 'admin_users.created_at', + 'admin_users.updated_at', + 'roles.id as role_id', + 'roles.name as role_name', + 'roles.display_name as role_display_name' + ) + .first(); + + if (!user) { + throw new NotFoundError('Admin user', id); + } + + return user; +} + +/** + * Update admin user + * @param {number} id - User ID to update + * @param {object} updates - Fields to update + * @param {number} updatedById - ID of user making the update + * @returns {Promise} Updated user + */ +async function updateAdminUser(id, updates, updatedById) { + const user = await db('admin_users').where('id', id).first(); + if (!user) { + throw new NotFoundError('Admin user', id); + } + + const allowedUpdates = {}; + + if (updates.username !== undefined) { + const existing = await db('admin_users') + .where('username', updates.username) + .whereNot('id', id) + .first(); + if (existing) { + throw new ConflictError('Username already taken', 'username'); + } + allowedUpdates.username = updates.username; + } + + if (updates.email !== undefined) { + const existing = await db('admin_users') + .where('email', updates.email) + .whereNot('id', id) + .first(); + if (existing) { + throw new ConflictError('Email already in use', 'email'); + } + allowedUpdates.email = updates.email; + } + + if (updates.role_id !== undefined) { + const role = await db('roles').where('id', updates.role_id).first(); + if (!role) { + throw new NotFoundError('Role', updates.role_id); + } + allowedUpdates.role_id = updates.role_id; + } + + if (updates.is_active !== undefined) { + allowedUpdates.is_active = formatBoolean(updates.is_active); + } + + allowedUpdates.updated_at = new Date(); + + await db('admin_users').where('id', id).update(allowedUpdates); + + await logActivity('admin_user_updated', + { userId: id, changes: Object.keys(allowedUpdates) }, + null, + { type: 'admin', id: updatedById, name: 'system' } + ); + + return getAdminUserById(id); +} + +/** + * Deactivate admin user + * @param {number} id - User ID to deactivate + * @param {number} deactivatedById - ID of user performing deactivation + */ +async function deactivateAdminUser(id, deactivatedById) { + const user = await db('admin_users').where('id', id).first(); + if (!user) { + throw new NotFoundError('Admin user', id); + } + + // Prevent self-deactivation + if (id === deactivatedById) { + throw new ValidationError('Cannot deactivate your own account'); + } + + // Check if this is the last super_admin + const superAdminRole = await db('roles').where('name', 'super_admin').first(); + if (user.role_id === superAdminRole?.id) { + const superAdminCount = await db('admin_users') + .where('role_id', superAdminRole.id) + .where('is_active', formatBoolean(true)) + .count('id as count') + .first(); + + if (Number(superAdminCount?.count) <= 1) { + throw new ValidationError('Cannot deactivate the last Super Admin'); + } + } + + await db('admin_users').where('id', id).update({ + is_active: formatBoolean(false), + updated_at: new Date() + }); + + await logActivity('admin_user_deactivated', + { userId: id, username: user.username }, + null, + { type: 'admin', id: deactivatedById, name: 'system' } + ); + + logger.info('Admin user deactivated', { userId: id, deactivatedById }); +} + +/** + * Reset admin user password (generates new password) + * @param {number} id - User ID + * @param {number} resetById - ID of user performing reset + * @returns {Promise} Result with email and status + */ +async function resetAdminPassword(id, resetById) { + const user = await db('admin_users').where('id', id).first(); + if (!user) { + throw new NotFoundError('Admin user', id); + } + + const newPassword = generateReadablePassword(); + const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds()); + + await db('admin_users').where('id', id).update({ + password_hash: passwordHash, + must_change_password: formatBoolean(true), + password_changed_at: new Date(), + updated_at: new Date() + }); + + // Queue password reset email + await queueEmail(null, user.email, 'admin_password_reset', { + username: user.username, + new_password: newPassword + }); + + await logActivity('admin_password_reset', + { userId: id, username: user.username }, + null, + { type: 'admin', id: resetById, name: 'system' } + ); + + logger.info('Admin password reset', { userId: id, resetById }); + + return { email: user.email, passwordSent: true }; +} + +/** + * Get all roles + * @returns {Promise} + */ +async function getAllRoles() { + return db('roles') + .select('id', 'name', 'display_name', 'description', 'is_system', 'priority') + .orderBy('priority', 'desc'); +} + +/** + * Get pending invitations + * @returns {Promise} + */ +async function getPendingInvitations() { + return db('admin_invitations') + .join('roles', 'roles.id', 'admin_invitations.role_id') + .join('admin_users', 'admin_users.id', 'admin_invitations.invited_by') + .whereNull('admin_invitations.accepted_at') + .where('admin_invitations.expires_at', '>', new Date()) + .select( + 'admin_invitations.id', + 'admin_invitations.email', + 'admin_invitations.expires_at', + 'admin_invitations.created_at', + 'roles.display_name as role_name', + 'admin_users.username as invited_by' + ) + .orderBy('admin_invitations.created_at', 'desc'); +} + +/** + * Cancel/delete an invitation + * @param {number} id - Invitation ID + * @param {number} cancelledById - ID of user cancelling + */ +async function cancelInvitation(id, cancelledById) { + const invitation = await db('admin_invitations').where('id', id).first(); + if (!invitation) { + throw new NotFoundError('Invitation', id); + } + + await db('admin_invitations').where('id', id).del(); + + await logActivity('admin_invitation_cancelled', + { invitationId: id, email: invitation.email }, + null, + { type: 'admin', id: cancelledById, name: 'system' } + ); + + logger.info('Admin invitation cancelled', { invitationId: id, cancelledById }); +} + +/** + * Validate an invitation token + * @param {string} token - Invitation token + * @returns {Promise} Invitation details if valid + */ +async function validateInvitationToken(token) { + const invitation = await db('admin_invitations') + .join('roles', 'roles.id', 'admin_invitations.role_id') + .where('admin_invitations.token', token) + .whereNull('admin_invitations.accepted_at') + .where('admin_invitations.expires_at', '>', new Date()) + .select( + 'admin_invitations.email', + 'admin_invitations.expires_at', + 'roles.display_name as role_name' + ) + .first(); + + return invitation || null; +} + +module.exports = { + createInvitation, + acceptInvitation, + getAllAdminUsers, + getAdminUserById, + updateAdminUser, + deactivateAdminUser, + resetAdminPassword, + getAllRoles, + getPendingInvitations, + cancelInvitation, + validateInvitationToken +}; diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 36c5482c..c8342f95 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -30,8 +30,8 @@ COPY . . # Build the application RUN npm run build -# Production stage (use Alpine 3.22+ with patched libpng/c-ares) -FROM nginx:1.27-alpine3.22 +# Production stage (use Alpine with patched libpng/c-ares) +FROM nginx:1.27-alpine # Upgrade all packages to fix security vulnerabilities # This ensures libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5fa71a46..ade2b1bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1088,9 +1088,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -1604,9 +1604,9 @@ ] }, "node_modules/@tanstack/query-core": { - "version": "5.90.11", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz", - "integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==", + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", + "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", "license": "MIT", "funding": { "type": "github", @@ -1614,12 +1614,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.11", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz", - "integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==", + "version": "5.90.16", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", + "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.11" + "@tanstack/query-core": "5.90.16" }, "funding": { "type": "github", @@ -1688,9 +1688,9 @@ } }, "node_modules/@testing-library/react": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", "dev": true, "license": "MIT", "dependencies": { @@ -2935,9 +2935,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", - "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", "dev": true, "funding": [ { @@ -2955,10 +2955,9 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.27.0", - "caniuse-lite": "^1.0.30001754", + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", - "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, @@ -2991,9 +2990,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", + "version": "2.9.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz", + "integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3038,9 +3037,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", "dev": true, "funding": [ { @@ -3059,11 +3058,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" @@ -3116,9 +3115,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", + "version": "1.0.30001762", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", + "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", "dev": true, "funding": [ { @@ -3522,9 +3521,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", - "integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3545,9 +3544,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.262", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", - "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", "dev": true, "license": "ISC" }, @@ -3680,9 +3679,9 @@ } }, "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", "peer": true, @@ -3693,7 +3692,7 @@ "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", + "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3754,9 +3753,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", - "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -4325,9 +4324,9 @@ } }, "node_modules/i18next": { - "version": "25.6.3", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz", - "integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==", + "version": "25.7.3", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz", + "integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==", "funding": [ { "type": "individual", @@ -5022,16 +5021,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/nwsapi": { "version": "2.2.22", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", @@ -6517,9 +6506,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ee84188f..0777c668 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,9 +10,9 @@ import { ThemeProvider } from './contexts/ThemeContext'; import { GalleryPage } from './pages/GalleryPage'; import { PreviewPage } from './pages/gallery/PreviewPage'; import { LegalPage } from './pages/public/LegalPage'; -import { - AdminLoginPage, - AdminDashboard, +import { + AdminLoginPage, + AdminDashboard, EventsListPage, CreateEventPage, EventDetailsPage, @@ -23,8 +23,10 @@ import { BrandingPage, SettingsPage, BackupManagement, - CMSPage + CMSPage, + UserManagementPage } from './pages/admin'; +import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; @@ -128,10 +130,14 @@ function App() { } /> } /> } /> + } /> } /> + {/* Public invitation acceptance page */} + } /> + {/* Public legal pages */} } /> } /> diff --git a/frontend/src/components/admin/AdminAuthWrapper.tsx b/frontend/src/components/admin/AdminAuthWrapper.tsx index 4647fffb..b0d4cb98 100644 --- a/frontend/src/components/admin/AdminAuthWrapper.tsx +++ b/frontend/src/components/admin/AdminAuthWrapper.tsx @@ -1,11 +1,13 @@ import React from 'react'; import { Outlet } from 'react-router-dom'; -import { AdminAuthProvider } from '../../contexts'; +import { AdminAuthProvider, PermissionsProvider } from '../../contexts'; export const AdminAuthWrapper: React.FC = () => { return ( - + + + ); }; diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index f4616c89..f6ac4fd8 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -1,21 +1,23 @@ import React from 'react'; import { NavLink, useLocation } from 'react-router-dom'; -import { - LayoutDashboard, - Calendar, - Mail, - Archive, - BarChart3, +import { + LayoutDashboard, + Calendar, + Mail, + Archive, + BarChart3, Settings, X, Palette, FileText, - HardDrive + HardDrive, + Users } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { settingsService } from '../../services/settings.service'; import { VersionInfo } from './VersionInfo'; +import { usePermissions } from '../../contexts/PermissionsContext'; interface AdminSidebarProps { isOpen: boolean; @@ -26,23 +28,32 @@ interface NavItem { nameKey: string; href: string; icon: React.ComponentType<{ className?: string }>; + permission?: string; } const navigation: NavItem[] = [ { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard }, - { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar }, - { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive }, - { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 }, - { nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail }, - { nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette }, - { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings }, - { nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive }, - { nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText }, + { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, + { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, + { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view' }, + { nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' }, + { nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' }, + { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' }, + { nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' }, + { nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' }, + { nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' }, ]; export const AdminSidebar: React.FC = ({ isOpen, onClose }) => { const location = useLocation(); const { t } = useTranslation(); + const { hasPermission } = usePermissions(); + + // Filter navigation items based on permissions + const filteredNavigation = navigation.filter(item => { + if (!item.permission) return true; + return hasPermission(item.permission); + }); return (
= ({ isOpen, onClose }) = {/* Navigation */} - {/* Bottom section - sticky to bottom */} -
- {/* Version Info */} - - - {/* Storage Info */} - -
+ {/* Bottom section - sticky to bottom (only for users with settings.view permission) */} + {hasPermission('settings.view') && ( +
+ {/* Version Info */} + + + {/* Storage Info */} + +
+ )}
); @@ -111,14 +124,9 @@ const StorageInfo: React.FC = () => { refetchInterval: 60000 // Refresh every minute }); + // Don't render anything while loading or if data failed to load if (!storageInfo) { - return ( -
-
-
-
-
- ); + return null; } const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1; diff --git a/frontend/src/components/admin/PermissionGate.tsx b/frontend/src/components/admin/PermissionGate.tsx new file mode 100644 index 00000000..432d8299 --- /dev/null +++ b/frontend/src/components/admin/PermissionGate.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import { usePermissions } from '../../contexts/PermissionsContext'; + +interface PermissionGateProps { + permission?: string; + permissions?: string[]; + requireAll?: boolean; + fallback?: ReactNode; + children: ReactNode; +} + +/** + * PermissionGate component that conditionally renders children based on user permissions. + * + * @param permission - A single permission to check + * @param permissions - An array of permissions to check + * @param requireAll - If true, requires all permissions (AND logic). If false, requires any permission (OR logic). Default: false + * @param fallback - Content to render if permission check fails. Default: null + * @param children - Content to render if permission check passes + */ +export const PermissionGate: React.FC = ({ + permission, + permissions, + requireAll = false, + fallback = null, + children, +}) => { + const { hasPermission, hasAnyPermission, hasAllPermissions, isSuperAdmin } = usePermissions(); + + // Super admin bypasses all permission checks + if (isSuperAdmin) { + return <>{children}; + } + + // Check single permission + if (permission) { + if (hasPermission(permission)) { + return <>{children}; + } + return <>{fallback}; + } + + // Check multiple permissions + if (permissions && permissions.length > 0) { + const hasAccess = requireAll + ? hasAllPermissions(permissions) + : hasAnyPermission(permissions); + + if (hasAccess) { + return <>{children}; + } + return <>{fallback}; + } + + // If no permissions specified, render children (allow access) + return <>{children}; +}; + +PermissionGate.displayName = 'PermissionGate'; diff --git a/frontend/src/components/admin/RestoreWizard.jsx b/frontend/src/components/admin/RestoreWizard.jsx index 3ee571bd..3409bb2a 100644 --- a/frontend/src/components/admin/RestoreWizard.jsx +++ b/frontend/src/components/admin/RestoreWizard.jsx @@ -500,13 +500,18 @@ export const RestoreWizard = () => {
{t('backup.restore.confirmation.spaceCheck.required')}: - {formatBytes(validationResult.spaceCheck.required)} + + {validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)} +
{t('backup.restore.confirmation.spaceCheck.available')}: - {formatBytes(validationResult.spaceCheck.available)} + + {validationResult.spaceCheck.availableFormatted || + (validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))} +
- {!validationResult.spaceCheck.sufficient && ( + {validationResult.spaceCheck.sufficient === false && (

{t('backup.restore.confirmation.spaceCheck.insufficient')} diff --git a/frontend/src/contexts/PermissionsContext.tsx b/frontend/src/contexts/PermissionsContext.tsx new file mode 100644 index 00000000..d4c60995 --- /dev/null +++ b/frontend/src/contexts/PermissionsContext.tsx @@ -0,0 +1,121 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import type { ReactNode } from 'react'; +import { api } from '../config/api'; +import { useAdminAuth } from './AdminAuthContext'; +import type { AdminPermissions } from '../types'; + +interface PermissionsContextType { + permissions: string[]; + role: { name: string; displayName: string } | null; + hasPermission: (permission: string) => boolean; + hasAnyPermission: (permissions: string[]) => boolean; + hasAllPermissions: (permissions: string[]) => boolean; + isSuperAdmin: boolean; + isLoading: boolean; + refresh: () => Promise; +} + +const PermissionsContext = createContext(undefined); + +export const usePermissions = () => { + const context = useContext(PermissionsContext); + if (!context) { + throw new Error('usePermissions must be used within a PermissionsProvider'); + } + return context; +}; + +interface PermissionsProviderProps { + children: ReactNode; +} + +export const PermissionsProvider: React.FC = ({ children }) => { + const { isAuthenticated } = useAdminAuth(); + const [permissions, setPermissions] = useState([]); + const [role, setRole] = useState<{ name: string; displayName: string } | null>(null); + const [isLoading, setIsLoading] = useState(true); + + const fetchPermissions = useCallback(async () => { + if (!isAuthenticated) { + setPermissions([]); + setRole(null); + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + const response = await api.get('/admin/users/me/permissions'); + setPermissions(response.data.permissions || []); + setRole(response.data.role || null); + } catch (error) { + // Clear permissions on auth failure + setPermissions([]); + setRole(null); + } finally { + setIsLoading(false); + } + }, [isAuthenticated]); + + useEffect(() => { + fetchPermissions(); + }, [fetchPermissions]); + + const hasPermission = useCallback( + (permission: string): boolean => { + // Super admin has all permissions + if (role?.name === 'super_admin') { + return true; + } + return permissions.includes(permission); + }, + [permissions, role] + ); + + const hasAnyPermission = useCallback( + (perms: string[]): boolean => { + // Super admin has all permissions + if (role?.name === 'super_admin') { + return true; + } + return perms.some((p) => permissions.includes(p)); + }, + [permissions, role] + ); + + const hasAllPermissions = useCallback( + (perms: string[]): boolean => { + // Super admin has all permissions + if (role?.name === 'super_admin') { + return true; + } + return perms.every((p) => permissions.includes(p)); + }, + [permissions, role] + ); + + const isSuperAdmin = role?.name === 'super_admin'; + + const refresh = useCallback(async () => { + await fetchPermissions(); + }, [fetchPermissions]); + + return ( + + {children} + + ); +}; + +export { PermissionsContext }; diff --git a/frontend/src/contexts/index.ts b/frontend/src/contexts/index.ts index ff5458af..e22cb463 100644 --- a/frontend/src/contexts/index.ts +++ b/frontend/src/contexts/index.ts @@ -3,4 +3,5 @@ export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext'; export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext'; export type { ThemeConfig, EventTheme } from './ThemeContext'; export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility -export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext'; \ No newline at end of file +export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext'; +export { PermissionsProvider, usePermissions, PermissionsContext } from './PermissionsContext'; \ No newline at end of file diff --git a/frontend/src/features/settings/tabs/ImageSecurityTab.tsx b/frontend/src/features/settings/tabs/ImageSecurityTab.tsx index c41433a5..de00d11f 100644 --- a/frontend/src/features/settings/tabs/ImageSecurityTab.tsx +++ b/frontend/src/features/settings/tabs/ImageSecurityTab.tsx @@ -48,7 +48,7 @@ export const ImageSecurityTab: React.FC = () => { const { data: fetchedSettings, isLoading, error } = useQuery({ queryKey: ['image-security-settings'], queryFn: async () => { - const response = await api.get('/api/admin/image-security/settings'); + const response = await api.get('/admin/image-security/settings'); return response.data; }, }); @@ -66,7 +66,7 @@ export const ImageSecurityTab: React.FC = () => { // Save mutation const saveMutation = useMutation({ mutationFn: async (newSettings: ImageSecuritySettings) => { - const response = await api.put('/api/admin/image-security/settings', newSettings); + const response = await api.put('/admin/image-security/settings', newSettings); return response.data; }, onSuccess: () => { diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index de0d1836..a29d2fa0 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,4 +1,5 @@ export * from './useSessionTimeout'; export * from './useOnClickOutside'; export * from './useLocalizedDate'; -export * from './useLocalizedTimeAgo'; \ No newline at end of file +export * from './useLocalizedTimeAgo'; +export * from './usePermission'; \ No newline at end of file diff --git a/frontend/src/hooks/usePermission.ts b/frontend/src/hooks/usePermission.ts new file mode 100644 index 00000000..e5cc5bc0 --- /dev/null +++ b/frontend/src/hooks/usePermission.ts @@ -0,0 +1,23 @@ +import { usePermissions } from '../contexts/PermissionsContext'; + +/** + * Hook to check if the current user has a specific permission. + * + * @param permission - The permission to check + * @returns boolean indicating if the user has the permission + */ +export function usePermission(permission: string): boolean { + const { hasPermission } = usePermissions(); + return hasPermission(permission); +} + +/** + * Hook to check if the current user has any of the specified permissions. + * + * @param permissions - Array of permissions to check + * @returns boolean indicating if the user has any of the permissions + */ +export function useAnyPermission(permissions: string[]): boolean { + const { hasAnyPermission } = usePermissions(); + return hasAnyPermission(permissions); +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 68dd90a4..5105eb20 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1,4 +1,78 @@ { + "userManagement": { + "title": "Benutzerverwaltung", + "subtitle": "Admin-Benutzer und Einladungen verwalten", + "loading": "Lade Benutzer...", + "loadError": "Fehler beim Laden der Benutzer. Bitte versuchen Sie es erneut.", + "inviteUser": "Benutzer einladen", + "createInvitation": "Einladung erstellen", + "email": "E-Mail-Adresse", + "emailPlaceholder": "benutzer@beispiel.de", + "role": "Rolle", + "selectRole": "Rolle auswählen", + "sendInvitation": "Einladung senden", + "editUser": "Benutzer bearbeiten", + "editingUser": "Bearbeite Benutzer", + "saveChanges": "Änderungen speichern", + "deactivateUser": "Benutzer deaktivieren", + "cancelInvitation": "Einladung abbrechen", + "invitationSent": "Einladung erfolgreich gesendet", + "invitationError": "Fehler beim Senden der Einladung", + "invitationCancelled": "Einladung abgebrochen", + "cancelInvitationError": "Fehler beim Abbrechen der Einladung", + "userUpdated": "Benutzer erfolgreich aktualisiert", + "updateUserError": "Fehler beim Aktualisieren des Benutzers", + "userDeactivated": "Benutzer erfolgreich deaktiviert", + "deactivateUserError": "Fehler beim Deaktivieren des Benutzers", + "noRole": "Keine Rolle", + "neverLoggedIn": "Nie angemeldet", + "expired": "Abgelaufen", + "deactivate": "Deaktivieren", + "cancel": "Abbrechen", + "tabs": { + "users": "Benutzer", + "invitations": "Einladungen" + }, + "stats": { + "totalUsers": "Benutzer gesamt", + "activeUsers": "Aktive Benutzer", + "pendingInvitations": "Ausstehende Einladungen", + "inactiveUsers": "Inaktive Benutzer" + }, + "status": { + "active": "Aktiv", + "inactive": "Inaktiv" + }, + "table": { + "user": "Benutzer", + "email": "E-Mail", + "role": "Rolle", + "status": "Status", + "lastLogin": "Letzte Anmeldung", + "actions": "Aktionen", + "invitedBy": "Eingeladen von", + "expires": "Läuft ab" + }, + "validation": { + "emailRequired": "E-Mail ist erforderlich", + "emailInvalid": "Ungültiges E-Mail-Format", + "roleRequired": "Rolle ist erforderlich" + }, + "searchUsersPlaceholder": "Benutzer suchen...", + "searchInvitationsPlaceholder": "Einladungen suchen...", + "noUsers": "Keine Benutzer gefunden", + "noUsersFound": "Keine Benutzer entsprechen Ihrer Suche", + "noInvitations": "Keine ausstehenden Einladungen", + "noInvitationsFound": "Keine Einladungen entsprechen Ihrer Suche", + "confirmDeactivate": { + "title": "Benutzer deaktivieren", + "message": "Sind Sie sicher, dass Sie {{name}} deaktivieren möchten? Sie können sich dann nicht mehr anmelden." + }, + "confirmCancelInvitation": { + "title": "Einladung abbrechen", + "message": "Sind Sie sicher, dass Sie die Einladung für {{email}} abbrechen möchten?" + } + }, "common": { "loading": "Wird geladen...", "error": "Fehler", @@ -84,7 +158,8 @@ "analytics": "Analytik", "emailSettings": "E-Mail-Einstellungen", "backup": "Backup & Wiederherstellung", - "cmsPages": "CMS-Seiten" + "cmsPages": "CMS-Seiten", + "users": "Benutzer" }, "backup": { "external": { @@ -153,6 +228,10 @@ "title": "Backup nicht konfiguriert", "message": "Bitte konfigurieren Sie die Backup-Einstellungen im Tab \"Konfiguration\", bevor Sie Backups ausführen." }, + "actions": { + "runBackupNow": "Backup jetzt starten", + "running": "Läuft..." + }, "coverage": { "title": "Backup-Abdeckung", "database": "Datenbank", @@ -1282,6 +1361,88 @@ "admin_logout": "Admin {{actorName}} abgemeldet", "system_activity": "Systemaktivität: {{type}}", "unknown": "Unbekannte Aktivität" + }, + "userManagement": "Benutzerverwaltung", + "inviteUser": "Benutzer einladen", + "pendingInvitations": "Ausstehende Einladungen", + "roles": { + "super_admin": "Super-Admin", + "admin": "Admin", + "editor": "Redakteur", + "viewer": "Betrachter" + }, + "userStatus": { + "active": "Aktiv", + "inactive": "Inaktiv" + }, + "inviteForm": { + "email": "E-Mail-Adresse", + "role": "Rolle", + "send": "Einladung senden" + }, + "acceptInvite": { + "title": "Admin-Einladung annehmen", + "username": "Benutzernamen wählen", + "password": "Passwort erstellen", + "submit": "Konto erstellen" + } + }, + "permissions": { + "insufficient": "Sie haben keine Berechtigung, diese Aktion auszuführen", + "viewOnly": "Nur Ansicht" + }, + "acceptInvitation": { + "title": "Einladung annehmen", + "subtitle": "Erstellen Sie Ihr Administratorkonto", + "validating": "Einladung wird überprüft...", + "invalidToken": "Ungültige Einladung", + "invalidTokenMessage": "Dieser Einladungslink ist ungültig oder abgelaufen. Bitte kontaktieren Sie Ihren Administrator für eine neue Einladung.", + "expiredToken": "Einladung abgelaufen", + "expiredTokenMessage": "Diese Einladung ist abgelaufen. Bitte fordern Sie eine neue Einladung von Ihrem Administrator an.", + "alreadyUsed": "Einladung bereits verwendet", + "alreadyUsedMessage": "Diese Einladung wurde bereits verwendet, um ein Konto zu erstellen.", + "invitedAs": "Sie wurden eingeladen als", + "expiresAt": "Einladung läuft ab", + "usernameLabel": "Benutzername", + "usernamePlaceholder": "Wählen Sie einen Benutzernamen", + "usernameHelp": "3-50 Zeichen, nur Buchstaben, Zahlen, Unterstriche und Bindestriche", + "passwordLabel": "Passwort", + "passwordPlaceholder": "Erstellen Sie ein sicheres Passwort", + "confirmPasswordLabel": "Passwort bestätigen", + "confirmPasswordPlaceholder": "Bestätigen Sie Ihr Passwort", + "passwordStrength": "Passwortstärke", + "requirements": { + "title": "Passwortanforderungen:", + "minLength": "Mindestens 12 Zeichen", + "uppercase": "Mindestens ein Großbuchstabe", + "lowercase": "Mindestens ein Kleinbuchstabe", + "number": "Mindestens eine Zahl", + "special": "Mindestens ein Sonderzeichen" + }, + "strength": { + "weak": "Schwach", + "fair": "Mittel", + "good": "Gut", + "strong": "Stark" + }, + "createAccount": "Konto erstellen", + "creating": "Konto wird erstellt...", + "success": "Konto erstellt!", + "successMessage": "Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt mit Ihren Zugangsdaten anmelden.", + "redirecting": "Weiterleitung zur Anmeldung in {{seconds}}...", + "goToLogin": "Zur Anmeldung", + "errors": { + "usernameRequired": "Benutzername ist erforderlich", + "usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein", + "usernameTooLong": "Benutzername darf maximal 50 Zeichen lang sein", + "usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche enthalten", + "passwordRequired": "Passwort ist erforderlich", + "passwordTooShort": "Passwort muss mindestens 12 Zeichen lang sein", + "passwordsDoNotMatch": "Passwörter stimmen nicht überein", + "confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort", + "usernameTaken": "Dieser Benutzername ist bereits vergeben", + "emailTaken": "Ein Konto mit dieser E-Mail-Adresse existiert bereits", + "genericError": "Konto konnte nicht erstellt werden. Bitte versuchen Sie es erneut." } }, "errors": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e91b4d24..03673125 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1,4 +1,78 @@ { + "userManagement": { + "title": "User Management", + "subtitle": "Manage admin users and invitations", + "loading": "Loading users...", + "loadError": "Failed to load users. Please try again.", + "inviteUser": "Invite User", + "createInvitation": "Create Invitation", + "email": "Email Address", + "emailPlaceholder": "user@example.com", + "role": "Role", + "selectRole": "Select a role", + "sendInvitation": "Send Invitation", + "editUser": "Edit User", + "editingUser": "Editing user", + "saveChanges": "Save Changes", + "deactivateUser": "Deactivate User", + "cancelInvitation": "Cancel Invitation", + "invitationSent": "Invitation sent successfully", + "invitationError": "Failed to send invitation", + "invitationCancelled": "Invitation cancelled", + "cancelInvitationError": "Failed to cancel invitation", + "userUpdated": "User updated successfully", + "updateUserError": "Failed to update user", + "userDeactivated": "User deactivated successfully", + "deactivateUserError": "Failed to deactivate user", + "noRole": "No Role", + "neverLoggedIn": "Never logged in", + "expired": "Expired", + "deactivate": "Deactivate", + "cancel": "Cancel", + "tabs": { + "users": "Users", + "invitations": "Invitations" + }, + "stats": { + "totalUsers": "Total Users", + "activeUsers": "Active Users", + "pendingInvitations": "Pending Invitations", + "inactiveUsers": "Inactive Users" + }, + "status": { + "active": "Active", + "inactive": "Inactive" + }, + "table": { + "user": "User", + "email": "Email", + "role": "Role", + "status": "Status", + "lastLogin": "Last Login", + "actions": "Actions", + "invitedBy": "Invited By", + "expires": "Expires" + }, + "validation": { + "emailRequired": "Email is required", + "emailInvalid": "Invalid email format", + "roleRequired": "Role is required" + }, + "searchUsersPlaceholder": "Search users...", + "searchInvitationsPlaceholder": "Search invitations...", + "noUsers": "No users found", + "noUsersFound": "No users match your search", + "noInvitations": "No pending invitations", + "noInvitationsFound": "No invitations match your search", + "confirmDeactivate": { + "title": "Deactivate User", + "message": "Are you sure you want to deactivate {{name}}? They will no longer be able to log in." + }, + "confirmCancelInvitation": { + "title": "Cancel Invitation", + "message": "Are you sure you want to cancel the invitation for {{email}}?" + } + }, "common": { "loading": "Loading...", "error": "Error", @@ -84,7 +158,8 @@ "analytics": "Analytics", "emailSettings": "Email Settings", "backup": "Backup & Restore", - "cmsPages": "CMS Pages" + "cmsPages": "CMS Pages", + "users": "Users" }, "archives": { "title": "Archives", @@ -1015,6 +1090,88 @@ "admin_logout": "Admin {{actorName}} logged out", "system_activity": "System activity: {{type}}", "unknown": "Unknown activity" + }, + "userManagement": "User Management", + "inviteUser": "Invite User", + "pendingInvitations": "Pending Invitations", + "roles": { + "super_admin": "Super Admin", + "admin": "Admin", + "editor": "Editor", + "viewer": "Viewer" + }, + "userStatus": { + "active": "Active", + "inactive": "Inactive" + }, + "inviteForm": { + "email": "Email Address", + "role": "Role", + "send": "Send Invitation" + }, + "acceptInvite": { + "title": "Accept Admin Invitation", + "username": "Choose a Username", + "password": "Create Password", + "submit": "Create Account" + } + }, + "permissions": { + "insufficient": "You don't have permission to perform this action", + "viewOnly": "View Only" + }, + "acceptInvitation": { + "title": "Accept Invitation", + "subtitle": "Create your admin account", + "validating": "Validating invitation...", + "invalidToken": "Invalid Invitation", + "invalidTokenMessage": "This invitation link is invalid or has expired. Please contact your administrator for a new invitation.", + "expiredToken": "Invitation Expired", + "expiredTokenMessage": "This invitation has expired. Please request a new invitation from your administrator.", + "alreadyUsed": "Invitation Already Used", + "alreadyUsedMessage": "This invitation has already been used to create an account.", + "invitedAs": "You've been invited as", + "expiresAt": "Invitation expires", + "usernameLabel": "Username", + "usernamePlaceholder": "Choose a username", + "usernameHelp": "3-50 characters, letters, numbers, underscores, and hyphens only", + "passwordLabel": "Password", + "passwordPlaceholder": "Create a strong password", + "confirmPasswordLabel": "Confirm Password", + "confirmPasswordPlaceholder": "Confirm your password", + "passwordStrength": "Password strength", + "requirements": { + "title": "Password requirements:", + "minLength": "At least 12 characters", + "uppercase": "At least one uppercase letter", + "lowercase": "At least one lowercase letter", + "number": "At least one number", + "special": "At least one special character" + }, + "strength": { + "weak": "Weak", + "fair": "Fair", + "good": "Good", + "strong": "Strong" + }, + "createAccount": "Create Account", + "creating": "Creating account...", + "success": "Account Created!", + "successMessage": "Your account has been created successfully. You can now log in with your credentials.", + "redirecting": "Redirecting to login in {{seconds}}...", + "goToLogin": "Go to Login", + "errors": { + "usernameRequired": "Username is required", + "usernameTooShort": "Username must be at least 3 characters", + "usernameTooLong": "Username must be at most 50 characters", + "usernameInvalid": "Username can only contain letters, numbers, underscores, and hyphens", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 12 characters", + "passwordsDoNotMatch": "Passwords do not match", + "confirmPasswordRequired": "Please confirm your password", + "usernameTaken": "This username is already taken", + "emailTaken": "An account with this email already exists", + "genericError": "Failed to create account. Please try again." } }, "errors": { @@ -1233,6 +1390,10 @@ "title": "Backup Not Configured", "message": "Please configure backup settings in the Configuration tab before running backups." }, + "actions": { + "runBackupNow": "Run Backup Now", + "running": "Running..." + }, "coverage": { "title": "Backup Coverage", "database": "Database", diff --git a/frontend/src/pages/admin/UserManagementPage.tsx b/frontend/src/pages/admin/UserManagementPage.tsx new file mode 100644 index 00000000..2eb1c799 --- /dev/null +++ b/frontend/src/pages/admin/UserManagementPage.tsx @@ -0,0 +1,973 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { + Users, + Mail, + Plus, + Search, + Edit, + UserX, + X, + AlertTriangle, + Clock, + Shield, + Trash2, + CheckCircle, + XCircle, +} from 'lucide-react'; +import { parseISO, formatDistanceToNow, isPast } from 'date-fns'; + +import { Button, Input, Card, Loading } from '../../components/common'; +import { userManagementService } from '../../services/userManagement.service'; +import type { AdminUser, AdminRole, AdminInvitation } from '../../types'; + +type TabType = 'users' | 'invitations'; + +// Role badge colors +const getRoleBadgeColor = (roleName: string): string => { + switch (roleName?.toLowerCase()) { + case 'super_admin': + return 'bg-red-100 text-red-700 border-red-200'; + case 'admin': + return 'bg-blue-100 text-blue-700 border-blue-200'; + case 'editor': + return 'bg-green-100 text-green-700 border-green-200'; + case 'viewer': + default: + return 'bg-neutral-100 text-neutral-700 border-neutral-200'; + } +}; + +// Modal component for creating invitations +interface CreateInvitationModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (email: string, roleId: number) => void; + roles: AdminRole[]; + isLoading: boolean; +} + +const CreateInvitationModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + roles, + isLoading, +}) => { + const { t } = useTranslation(); + const [email, setEmail] = useState(''); + const [roleId, setRoleId] = useState(''); + const [errors, setErrors] = useState<{ email?: string; role?: string }>({}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const newErrors: { email?: string; role?: string } = {}; + + if (!email) { + newErrors.email = t('userManagement.validation.emailRequired'); + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + newErrors.email = t('userManagement.validation.emailInvalid'); + } + + if (!roleId) { + newErrors.role = t('userManagement.validation.roleRequired'); + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + onSubmit(email, roleId as number); + }; + + const handleClose = () => { + setEmail(''); + setRoleId(''); + setErrors({}); + onClose(); + }; + + if (!isOpen) return null; + + return ( +

+ +
+
+

+ {t('userManagement.createInvitation')} +

+ +
+ +
+
+
+ + { + setEmail(e.target.value); + setErrors((prev) => ({ ...prev, email: undefined })); + }} + placeholder={t('userManagement.emailPlaceholder')} + disabled={isLoading} + /> + {errors.email && ( +

{errors.email}

+ )} +
+ +
+ + + {errors.role && ( +

{errors.role}

+ )} +
+
+ +
+ + +
+
+
+
+
+ ); +}; + +// Modal component for editing users +interface EditUserModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (userId: number, roleId: number) => void; + user: AdminUser | null; + roles: AdminRole[]; + isLoading: boolean; +} + +const EditUserModal: React.FC = ({ + isOpen, + onClose, + onSubmit, + user, + roles, + isLoading, +}) => { + const { t } = useTranslation(); + const [roleId, setRoleId] = useState(''); + + React.useEffect(() => { + if (user?.roleId) { + setRoleId(user.roleId); + } + }, [user]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!user || !roleId) return; + onSubmit(user.id, roleId as number); + }; + + const handleClose = () => { + setRoleId(''); + onClose(); + }; + + if (!isOpen || !user) return null; + + return ( +
+ +
+
+

+ {t('userManagement.editUser')} +

+ +
+ +
+

+ {t('userManagement.editingUser')}: {user.username} +

+

{user.email}

+
+ +
+
+ + +
+ +
+ + +
+
+
+
+
+ ); +}; + +// Confirmation dialog component +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; + confirmText: string; + isLoading: boolean; + variant?: 'danger' | 'warning'; +} + +const ConfirmDialog: React.FC = ({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText, + isLoading, + variant = 'danger', +}) => { + const { t } = useTranslation(); + + if (!isOpen) return null; + + return ( +
+ +
+
+
+ +
+
+

{title}

+

{message}

+
+
+ +
+ + +
+
+
+
+ ); +}; + +export const UserManagementPage: React.FC = () => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + // State + const [activeTab, setActiveTab] = useState('users'); + const [searchTerm, setSearchTerm] = useState(''); + const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false); + const [showEditUserModal, setShowEditUserModal] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [confirmDialog, setConfirmDialog] = useState<{ + isOpen: boolean; + type: 'deactivate' | 'cancelInvitation'; + id: number; + name: string; + } | null>(null); + + // Queries + const { + data: users, + isLoading: usersLoading, + error: usersError, + } = useQuery({ + queryKey: ['admin-users'], + queryFn: userManagementService.getUsers, + }); + + const { + data: roles, + isLoading: rolesLoading, + } = useQuery({ + queryKey: ['admin-roles'], + queryFn: userManagementService.getRoles, + }); + + const { + data: invitations, + isLoading: invitationsLoading, + error: invitationsError, + } = useQuery({ + queryKey: ['admin-invitations'], + queryFn: userManagementService.getInvitations, + }); + + // Mutations + const createInvitationMutation = useMutation({ + mutationFn: ({ email, roleId }: { email: string; roleId: number }) => + userManagementService.createInvitation({ email, role_id: roleId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-invitations'] }); + setShowCreateInvitationModal(false); + toast.success(t('userManagement.invitationSent')); + }, + onError: (error: Error) => { + toast.error(error.message || t('userManagement.invitationError')); + }, + }); + + const cancelInvitationMutation = useMutation({ + mutationFn: userManagementService.cancelInvitation, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-invitations'] }); + setConfirmDialog(null); + toast.success(t('userManagement.invitationCancelled')); + }, + onError: () => { + toast.error(t('userManagement.cancelInvitationError')); + }, + }); + + const updateUserMutation = useMutation({ + mutationFn: ({ id, roleId }: { id: number; roleId: number }) => + userManagementService.updateUser(id, { roleId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + setShowEditUserModal(false); + setSelectedUser(null); + toast.success(t('userManagement.userUpdated')); + }, + onError: () => { + toast.error(t('userManagement.updateUserError')); + }, + }); + + const deactivateUserMutation = useMutation({ + mutationFn: userManagementService.deactivateUser, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-users'] }); + setConfirmDialog(null); + toast.success(t('userManagement.userDeactivated')); + }, + onError: () => { + toast.error(t('userManagement.deactivateUserError')); + }, + }); + + // Filtered data + const filteredUsers = useMemo(() => { + if (!users) return []; + if (!searchTerm) return users; + + const term = searchTerm.toLowerCase(); + return users.filter( + (user) => + user.username.toLowerCase().includes(term) || + user.email.toLowerCase().includes(term) || + user.roleName?.toLowerCase().includes(term) + ); + }, [users, searchTerm]); + + const filteredInvitations = useMemo(() => { + if (!invitations) return []; + if (!searchTerm) return invitations; + + const term = searchTerm.toLowerCase(); + return invitations.filter( + (invitation) => + invitation.email.toLowerCase().includes(term) || + invitation.roleName?.toLowerCase().includes(term) + ); + }, [invitations, searchTerm]); + + // Handlers + const handleCreateInvitation = (email: string, roleId: number) => { + createInvitationMutation.mutate({ email, roleId }); + }; + + const handleEditUser = (user: AdminUser) => { + setSelectedUser(user); + setShowEditUserModal(true); + }; + + const handleUpdateUser = (userId: number, roleId: number) => { + updateUserMutation.mutate({ id: userId, roleId }); + }; + + const handleDeactivateUser = (user: AdminUser) => { + setConfirmDialog({ + isOpen: true, + type: 'deactivate', + id: user.id, + name: user.username, + }); + }; + + const handleCancelInvitation = (invitation: AdminInvitation) => { + setConfirmDialog({ + isOpen: true, + type: 'cancelInvitation', + id: invitation.id, + name: invitation.email, + }); + }; + + const handleConfirmAction = () => { + if (!confirmDialog) return; + + if (confirmDialog.type === 'deactivate') { + deactivateUserMutation.mutate(confirmDialog.id); + } else if (confirmDialog.type === 'cancelInvitation') { + cancelInvitationMutation.mutate(confirmDialog.id); + } + }; + + // Loading state + const isLoading = usersLoading || rolesLoading || invitationsLoading; + + if (isLoading) { + return ( +
+
+

+ {t('userManagement.title')} +

+

{t('userManagement.subtitle')}

+
+
+ +
+
+ ); + } + + // Error state + if (usersError || invitationsError) { + return ( +
+
+

+ {t('userManagement.title')} +

+

{t('userManagement.subtitle')}

+
+
+

{t('userManagement.loadError')}

+ +
+
+ ); + } + + const tabs: { key: TabType; label: string; count: number }[] = [ + { key: 'users', label: t('userManagement.tabs.users'), count: users?.length || 0 }, + { + key: 'invitations', + label: t('userManagement.tabs.invitations'), + count: invitations?.length || 0, + }, + ]; + + return ( +
+ {/* Page Header */} +
+
+

+ {t('userManagement.title')} +

+

{t('userManagement.subtitle')}

+
+ +
+ + {/* Statistics Cards */} +
+ +
+
+

+ {t('userManagement.stats.totalUsers')} +

+

+ {users?.length || 0} +

+
+ +
+
+ + +
+
+

+ {t('userManagement.stats.activeUsers')} +

+

+ {users?.filter((u) => u.isActive).length || 0} +

+
+ +
+
+ + +
+
+

+ {t('userManagement.stats.pendingInvitations')} +

+

+ {invitations?.length || 0} +

+
+ +
+
+ + +
+
+

+ {t('userManagement.stats.inactiveUsers')} +

+

+ {users?.filter((u) => !u.isActive).length || 0} +

+
+ +
+
+
+ + {/* Tab Navigation */} +
+ +
+ + {/* Search */} + +
+
+ } + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + /> +
+
+
+ + {/* Users Tab Content */} + {activeTab === 'users' && ( + +
+ + + + + + + + + + + + {filteredUsers.length === 0 ? ( + + + + ) : ( + filteredUsers.map((user) => ( + + + + + + + + )) + )} + +
+ {t('userManagement.table.user')} + + {t('userManagement.table.role')} + + {t('userManagement.table.status')} + + {t('userManagement.table.lastLogin')} + + {t('userManagement.table.actions')} +
+ {searchTerm + ? t('userManagement.noUsersFound') + : t('userManagement.noUsers')} +
+
+
+ + {user.username.charAt(0).toUpperCase()} + +
+
+

+ {user.username} +

+

{user.email}

+
+
+
+ + + {user.roleDisplayName || user.roleName || t('userManagement.noRole')} + + + + {user.isActive + ? t('userManagement.status.active') + : t('userManagement.status.inactive')} + + + {user.lastLogin ? ( +
+ + {formatDistanceToNow(parseISO(user.lastLogin), { + addSuffix: true, + })} +
+ ) : ( + + {t('userManagement.neverLoggedIn')} + + )} +
+
+ + {user.isActive && ( + + )} +
+
+
+
+ )} + + {/* Invitations Tab Content */} + {activeTab === 'invitations' && ( + +
+ + + + + + + + + + + + {filteredInvitations.length === 0 ? ( + + + + ) : ( + filteredInvitations.map((invitation) => { + const isExpired = isPast(parseISO(invitation.expiresAt)); + return ( + + + + + + + + ); + }) + )} + +
+ {t('userManagement.table.email')} + + {t('userManagement.table.role')} + + {t('userManagement.table.invitedBy')} + + {t('userManagement.table.expires')} + + {t('userManagement.table.actions')} +
+ {searchTerm + ? t('userManagement.noInvitationsFound') + : t('userManagement.noInvitations')} +
+
+
+ +
+

+ {invitation.email} +

+
+
+ + + {invitation.roleName} + + + {invitation.invitedBy || '-'} + + + + {isExpired + ? t('userManagement.expired') + : formatDistanceToNow(parseISO(invitation.expiresAt), { + addSuffix: true, + })} + + + +
+
+
+ )} + + {/* Create Invitation Modal */} + setShowCreateInvitationModal(false)} + onSubmit={handleCreateInvitation} + roles={roles || []} + isLoading={createInvitationMutation.isPending} + /> + + {/* Edit User Modal */} + { + setShowEditUserModal(false); + setSelectedUser(null); + }} + onSubmit={handleUpdateUser} + user={selectedUser} + roles={roles || []} + isLoading={updateUserMutation.isPending} + /> + + {/* Confirmation Dialog */} + {confirmDialog && ( + setConfirmDialog(null)} + onConfirm={handleConfirmAction} + title={ + confirmDialog.type === 'deactivate' + ? t('userManagement.confirmDeactivate.title') + : t('userManagement.confirmCancelInvitation.title') + } + message={ + confirmDialog.type === 'deactivate' + ? t('userManagement.confirmDeactivate.message', { name: confirmDialog.name }) + : t('userManagement.confirmCancelInvitation.message', { + email: confirmDialog.name, + }) + } + confirmText={ + confirmDialog.type === 'deactivate' + ? t('userManagement.deactivate') + : t('userManagement.cancel') + } + isLoading={ + confirmDialog.type === 'deactivate' + ? deactivateUserMutation.isPending + : cancelInvitationMutation.isPending + } + variant={confirmDialog.type === 'deactivate' ? 'danger' : 'warning'} + /> + )} +
+ ); +}; + +UserManagementPage.displayName = 'UserManagementPage'; diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts index 408873e9..fdb5e0ba 100644 --- a/frontend/src/pages/admin/index.ts +++ b/frontend/src/pages/admin/index.ts @@ -10,4 +10,5 @@ export { BrandingPage } from './BrandingPage'; export { SettingsPage } from './SettingsPage'; export { CMSPage } from './CMSPage'; export { BackupManagement } from './BackupManagement'; -export { EventFeedbackPage } from './EventFeedbackPage'; \ No newline at end of file +export { EventFeedbackPage } from './EventFeedbackPage'; +export { UserManagementPage } from './UserManagementPage'; \ No newline at end of file diff --git a/frontend/src/pages/public/AcceptInvitePage.tsx b/frontend/src/pages/public/AcceptInvitePage.tsx new file mode 100644 index 00000000..08722d49 --- /dev/null +++ b/frontend/src/pages/public/AcceptInvitePage.tsx @@ -0,0 +1,559 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { + User, + Lock, + Eye, + EyeOff, + AlertCircle, + CheckCircle, + Mail, + Shield, + XCircle +} from 'lucide-react'; +import { toast } from 'react-toastify'; + +import { Button, Input, Card, Loading } from '../../components/common'; +import { api } from '../../config/api'; + +interface InvitationValidation { + valid: boolean; + email: string; + role: string; + expiresAt: string; +} + +interface AcceptInvitePayload { + username: string; + password: string; +} + +interface AcceptInviteResponse { + message: string; + email: string; +} + +interface PasswordRequirement { + label: string; + met: boolean; + test: (password: string) => boolean; +} + +export const AcceptInvitePage: React.FC = () => { + const { t } = useTranslation(); + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + + const [formData, setFormData] = useState({ + username: '', + password: '', + confirmPassword: '', + }); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + const [errors, setErrors] = useState>({}); + const [redirectCountdown, setRedirectCountdown] = useState(null); + + // Validate invitation token + const { + data: invitation, + isLoading: isValidating, + error: validationError, + isError + } = useQuery({ + queryKey: ['invitation', token], + queryFn: async () => { + const response = await api.get(`/invite/${token}`); + return response.data; + }, + enabled: !!token, + retry: false, + }); + + // Accept invitation mutation + const acceptMutation = useMutation({ + mutationFn: async (payload: AcceptInvitePayload) => { + const response = await api.post(`/invite/${token}`, payload); + return response.data; + }, + onSuccess: (data) => { + toast.success(data.message || t('acceptInvitation.success')); + setRedirectCountdown(5); + }, + onError: (error: any) => { + const errorMessage = error.response?.data?.error || error.response?.data?.message; + + if (error.response?.status === 400) { + // Validation errors + if (error.response?.data?.errors) { + const validationErrors: Record = {}; + error.response.data.errors.forEach((err: { field: string; message: string }) => { + validationErrors[err.field] = err.message; + }); + setErrors(validationErrors); + } else { + toast.error(errorMessage || t('acceptInvitation.validationError')); + } + } else if (error.response?.status === 422) { + toast.error(errorMessage || t('acceptInvitation.validationError')); + } else if (error.response?.status === 404) { + toast.error(t('acceptInvitation.invalidOrExpired')); + } else if (error.response?.status === 409) { + toast.error(errorMessage || t('acceptInvitation.alreadyUsed')); + } else { + toast.error(t('acceptInvitation.generalError')); + } + }, + }); + + // Password requirements + const passwordRequirements: PasswordRequirement[] = useMemo(() => [ + { + label: t('acceptInvitation.requirements.minLength'), + met: false, + test: (pwd: string) => pwd.length >= 8, + }, + { + label: t('acceptInvitation.requirements.uppercase'), + met: false, + test: (pwd: string) => /[A-Z]/.test(pwd), + }, + { + label: t('acceptInvitation.requirements.lowercase'), + met: false, + test: (pwd: string) => /[a-z]/.test(pwd), + }, + { + label: t('acceptInvitation.requirements.number'), + met: false, + test: (pwd: string) => /[0-9]/.test(pwd), + }, + { + label: t('acceptInvitation.requirements.special'), + met: false, + test: (pwd: string) => /[!@#$%^&*(),.?":{}|<>]/.test(pwd), + }, + ], [t]); + + // Calculate password strength + const passwordStrength = useMemo(() => { + const metCount = passwordRequirements.filter(req => req.test(formData.password)).length; + if (metCount === 0) return { level: 0, label: '', color: '' }; + if (metCount <= 2) return { level: 1, label: t('acceptInvitation.strength.weak'), color: 'bg-red-500' }; + if (metCount <= 3) return { level: 2, label: t('acceptInvitation.strength.fair'), color: 'bg-yellow-500' }; + if (metCount <= 4) return { level: 3, label: t('acceptInvitation.strength.good'), color: 'bg-blue-500' }; + return { level: 4, label: t('acceptInvitation.strength.strong'), color: 'bg-green-500' }; + }, [formData.password, passwordRequirements, t]); + + // Redirect countdown effect + useEffect(() => { + if (redirectCountdown === null) return; + + if (redirectCountdown === 0) { + navigate('/admin/login'); + return; + } + + const timer = setTimeout(() => { + setRedirectCountdown(prev => (prev !== null ? prev - 1 : null)); + }, 1000); + + return () => clearTimeout(timer); + }, [redirectCountdown, navigate]); + + // Validate username + const validateUsername = (username: string): string | null => { + if (!username) { + return t('acceptInvitation.usernameRequired'); + } + if (username.length < 3) { + return t('acceptInvitation.usernameTooShort'); + } + if (username.length > 50) { + return t('acceptInvitation.usernameTooLong'); + } + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + return t('acceptInvitation.usernameInvalid'); + } + return null; + }; + + // Validate form + const validateForm = (): boolean => { + const newErrors: Record = {}; + + const usernameError = validateUsername(formData.username); + if (usernameError) { + newErrors.username = usernameError; + } + + if (!formData.password) { + newErrors.password = t('acceptInvitation.passwordRequired'); + } else { + const allRequirementsMet = passwordRequirements.every(req => req.test(formData.password)); + if (!allRequirementsMet) { + newErrors.password = t('acceptInvitation.passwordRequirements'); + } + } + + if (!formData.confirmPassword) { + newErrors.confirmPassword = t('acceptInvitation.confirmPasswordRequired'); + } else if (formData.password !== formData.confirmPassword) { + newErrors.confirmPassword = t('acceptInvitation.passwordsDoNotMatch'); + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + acceptMutation.mutate({ + username: formData.username, + password: formData.password, + }); + }; + + const handleInputChange = (field: string) => (e: React.ChangeEvent) => { + setFormData(prev => ({ ...prev, [field]: e.target.value })); + // Clear error when user starts typing + if (errors[field]) { + setErrors(prev => ({ ...prev, [field]: '' })); + } + }; + + // Format role for display + const formatRole = (role: string): string => { + const roleKey = `admin.roles.${role}`; + const translated = t(roleKey); + // If translation not found, format the role nicely + if (translated === roleKey) { + return role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + } + return translated; + }; + + // Format expiration date + const formatExpirationDate = (dateString: string): string => { + try { + const date = new Date(dateString); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return dateString; + } + }; + + // Loading state + if (isValidating) { + return ( +
+
+ +
+
+ ); + } + + // Error state - invalid or expired token + if (isError || !invitation?.valid) { + const errorMessage = (validationError as any)?.response?.data?.error || t('acceptInvitation.invalidOrExpired'); + + return ( +
+
+ +
+
+ +
+

+ {t('acceptInvitation.invalidTitle')} +

+

+ {errorMessage} +

+

+ {t('acceptInvitation.contactAdmin')} +

+ +
+
+
+
+ ); + } + + // Success state - account created + if (acceptMutation.isSuccess) { + return ( +
+
+ +
+
+ +
+

+ {t('acceptInvitation.successTitle')} +

+

+ {t('acceptInvitation.successMessage')} +

+

+ {t('acceptInvitation.redirecting', { seconds: redirectCountdown })} +

+ +
+
+
+
+ ); + } + + // Form state - valid invitation + return ( +
+
+ {/* Header */} +
+
+ PicPeak +
+

+ {t('acceptInvitation.title')} +

+

+ {t('acceptInvitation.subtitle')} +

+
+ + {/* Invitation Info Card */} + +
+
+ +
+
+

{t('acceptInvitation.invitedAs')}

+

{invitation.email}

+
+ + + {formatRole(invitation.role)} + +
+

+ {t('acceptInvitation.expiresAt', { date: formatExpirationDate(invitation.expiresAt) })} +

+
+
+
+ + {/* Registration Form */} + +
+ {/* Form Error */} + {errors.form && ( +
+ +

{errors.form}

+
+ )} + + {/* Username Field */} +
+ + } + autoComplete="username" + autoFocus + /> +

+ {t('acceptInvitation.usernameHelp')} +

+
+ + {/* Password Field */} +
+ +
+ } + autoComplete="new-password" + /> + +
+ + {/* Password Strength Indicator */} + {formData.password && ( +
+
+ {t('acceptInvitation.passwordStrength')} + + {passwordStrength.label} + +
+
+
+
+
+ )} + + {/* Password Requirements */} +
+

{t('acceptInvitation.requirementsTitle')}

+ {passwordRequirements.map((req, index) => { + const isMet = req.test(formData.password); + return ( +
+ {isMet ? ( + + ) : ( +
+ )} + + {req.label} + +
+ ); + })} +
+
+ + {/* Confirm Password Field */} +
+ +
+ } + autoComplete="new-password" + /> + +
+ {formData.confirmPassword && formData.password === formData.confirmPassword && ( +
+ + {t('acceptInvitation.passwordsMatch')} +
+ )} +
+ + {/* Submit Button */} + + + + + {/* Footer */} +
+

+ {t('acceptInvitation.alreadyHaveAccount')}{' '} + + {t('acceptInvitation.signIn')} + +

+

+ {t('adminLogin.poweredBy')} +

+
+
+
+ ); +}; + +AcceptInvitePage.displayName = 'AcceptInvitePage'; diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 840946b6..e9a1b6ab 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -8,4 +8,5 @@ export { emailService } from './email.service'; export { settingsService } from './settings.service'; export { cmsService } from './cms.service'; export { notificationsService } from './notifications.service'; -export { feedbackService } from './feedback.service'; \ No newline at end of file +export { feedbackService } from './feedback.service'; +export { userManagementService } from './userManagement.service'; \ No newline at end of file diff --git a/frontend/src/services/userManagement.service.ts b/frontend/src/services/userManagement.service.ts new file mode 100644 index 00000000..ae1fe9f1 --- /dev/null +++ b/frontend/src/services/userManagement.service.ts @@ -0,0 +1,187 @@ +import { api } from '../config/api'; +import type { AdminUser, AdminRole, AdminInvitation } from '../types'; + +// Transform snake_case API response to camelCase for frontend +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function transformUser(user: any): AdminUser { + return { + id: user.id, + username: user.username, + email: user.email, + isActive: user.isActive ?? user.is_active, + lastLogin: user.lastLogin ?? user.last_login, + lastLoginIp: user.lastLoginIp ?? user.last_login_ip, + createdAt: user.createdAt ?? user.created_at, + updatedAt: user.updatedAt ?? user.updated_at, + roleId: user.roleId ?? user.role_id, + roleName: user.roleName ?? user.role_name, + roleDisplayName: user.roleDisplayName ?? user.role_display_name, + createdByUsername: user.createdByUsername ?? user.created_by_username, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function transformRole(role: any): AdminRole { + return { + id: role.id, + name: role.name, + displayName: role.displayName ?? role.display_name, + description: role.description, + isSystem: role.isSystem ?? role.is_system, + priority: role.priority, + }; +} + +interface GetUsersResponse { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + users: any[]; +} + +interface GetUserResponse { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + user: any; +} + +interface GetRolesResponse { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + roles: any[]; +} + +interface GetInvitationsResponse { + invitations: AdminInvitation[]; +} + +interface CreateInvitationData { + email: string; + role_id: number; +} + +interface CreateInvitationResponse { + invitation: AdminInvitation; + message: string; +} + +interface UpdateUserData { + roleId?: number; + isActive?: boolean; +} + +interface UpdateUserResponse { + user: AdminUser; +} + +interface DeactivateUserResponse { + message: string; +} + +interface ResetPasswordResponse { + temporaryPassword: string; + message: string; +} + +interface ValidateInvitationResponse { + valid: boolean; + email: string; + roleName: string; + invitedBy: string; + expiresAt: string; +} + +interface AcceptInvitationData { + username: string; + password: string; +} + +interface AcceptInvitationResponse { + message: string; + user: AdminUser; +} + +export const userManagementService = { + /** + * Get all admin users + */ + async getUsers(): Promise { + const response = await api.get('/admin/users'); + return response.data.users.map(transformUser); + }, + + /** + * Get a single admin user by ID + */ + async getUser(id: number): Promise { + const response = await api.get(`/admin/users/${id}`); + return transformUser(response.data.user); + }, + + /** + * Get all available roles + */ + async getRoles(): Promise { + const response = await api.get('/admin/users/roles'); + return response.data.roles.map(transformRole); + }, + + /** + * Get all pending invitations + */ + async getInvitations(): Promise { + const response = await api.get('/admin/users/invitations'); + return response.data.invitations; + }, + + /** + * Create a new invitation + */ + async createInvitation(data: CreateInvitationData): Promise { + const response = await api.post('/admin/users/invite', data); + return response.data.invitation; + }, + + /** + * Cancel a pending invitation + */ + async cancelInvitation(id: number): Promise { + await api.delete(`/admin/users/invitations/${id}`); + }, + + /** + * Update an admin user + */ + async updateUser(id: number, data: UpdateUserData): Promise { + const response = await api.put(`/admin/users/${id}`, data); + return transformUser(response.data.user); + }, + + /** + * Deactivate an admin user + */ + async deactivateUser(id: number): Promise { + const response = await api.post(`/admin/users/${id}/deactivate`); + return response.data.message; + }, + + /** + * Reset an admin user's password + */ + async resetPassword(id: number): Promise { + const response = await api.post(`/admin/users/${id}/reset-password`); + return response.data; + }, + + /** + * Validate an invitation token (public endpoint) + */ + async validateInvitation(token: string): Promise { + const response = await api.get(`/invite/${token}`); + return response.data; + }, + + /** + * Accept an invitation and create account (public endpoint) + */ + async acceptInvitation(token: string, data: AcceptInvitationData): Promise { + const response = await api.post(`/invite/${token}`, data); + return response.data; + }, +}; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index b64975bd..d9258ce9 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -144,6 +144,18 @@ export interface AdminUser { username: string; email: string; mustChangePassword?: boolean; + role?: { + name: string; + displayName: string; + }; + roleId?: number; + roleName?: string; + roleDisplayName?: string; + isActive?: boolean; + lastLogin?: string | null; + lastLoginIp?: string | null; + createdAt?: string; + createdByUsername?: string; } export interface LoginResponse { @@ -178,5 +190,32 @@ export interface ApiError { }>; } +// Role and Permission types +export interface AdminRole { + id: number; + name: string; + displayName: string; + description?: string; + isSystem?: boolean; + priority?: number; +} + +export interface AdminPermissions { + role: { + name: string; + displayName: string; + } | null; + permissions: string[]; +} + +export interface AdminInvitation { + id: number; + email: string; + roleName: string; + invitedBy: string; + expiresAt: string; + createdAt: string; +} + // Export protection types export * from './protection';