From 892e47d017064d7922536f8e138bbb290a45cdc9 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Wed, 7 Jan 2026 17:10:46 +0100 Subject: [PATCH] feat: add multi-administrator support with RBAC and fix backup/restore for S3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Multi-Administrator System - Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer) - Add granular permissions system for all admin operations - Add admin user management page with invite functionality - Add email invitation system for new administrators - Add permission middleware protecting all admin routes - Add PermissionGate component for frontend permission checks - Track event creator (created_by) for audit purposes ## Backup & Restore Fixes - Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility - Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format - Fix PostgreSQL compatibility: add .returning('id') for insert operations - Fix disk space check: use df command, handle unknown space gracefully - Fix dry-run validation to not block on warnings - Fix req.user → req.admin in restore routes ## Database Migrations - 054: Add roles table with predefined roles - 055: Add permissions table - 056: Add role_permissions junction table - 057: Add role_id to admin_users - 058: Add admin_invitations table - 059: Add admin email templates - 060: Add created_by to events table ## Other Improvements - Update .gitignore to exclude planning docs and local backup directory - Remove SQLite database file from tracking - Add i18n translations for user management (EN/DE) --- .gitignore | 5 + DEPLOYMENT_GUIDE.md | 73 +- backend/Dockerfile | 10 +- backend/data/photo_sharing.db | Bin 565248 -> 0 bytes .../migrations/core/054_add_roles_table.js | 91 ++ .../core/055_add_permissions_table.js | 122 +++ .../core/056_add_role_permissions_table.js | 134 +++ .../core/057_add_role_to_admin_users.js | 115 +++ .../core/058_add_admin_invitations_table.js | 68 ++ .../core/059_add_admin_email_templates.js | 239 +++++ .../core/060_add_events_created_by.js | 23 + backend/package-lock.json | 254 +++-- backend/package.json | 1 + backend/server.js | 2 + backend/src/middleware/auth.js | 47 +- backend/src/middleware/permissions.js | 242 +++++ backend/src/routes/acceptInvite.js | 75 ++ backend/src/routes/adminArchives.js | 11 +- backend/src/routes/adminBackup.js | 43 +- backend/src/routes/adminCMS.js | 7 +- backend/src/routes/adminCategories.js | 11 +- backend/src/routes/adminCssTemplates.js | 11 +- backend/src/routes/adminDashboard.js | 9 +- backend/src/routes/adminDatabaseBackup.js | 17 +- backend/src/routes/adminEmail.js | 13 +- backend/src/routes/adminEventRename.js | 5 +- backend/src/routes/adminEvents-enhanced.js | 3 +- backend/src/routes/adminEvents.js | 76 +- backend/src/routes/adminExternalMedia.js | 5 +- backend/src/routes/adminFeedback.js | 15 +- backend/src/routes/adminImageSecurity.js | 17 +- backend/src/routes/adminNotifications.js | 9 +- backend/src/routes/adminPhotoExport.js | 9 +- backend/src/routes/adminPhotos.js | 31 +- backend/src/routes/adminRestore.js | 166 ++- backend/src/routes/adminSettings.js | 31 +- backend/src/routes/adminSystem.js | 7 +- backend/src/routes/adminThumbnails.js | 9 +- backend/src/routes/adminUsers.js | 194 ++++ backend/src/routes/auth.js | 39 +- backend/src/services/backupService.js | 61 +- backend/src/services/emailProcessor.js | 3 + backend/src/services/restoreService.js | 62 +- backend/src/services/storage/s3Storage.js | 22 +- backend/src/services/userManagementService.js | 440 ++++++++ frontend/Dockerfile | 4 +- frontend/package-lock.json | 111 +- frontend/src/App.tsx | 14 +- .../src/components/admin/AdminAuthWrapper.tsx | 6 +- .../src/components/admin/AdminSidebar.tsx | 70 +- .../src/components/admin/PermissionGate.tsx | 60 ++ .../src/components/admin/RestoreWizard.jsx | 11 +- frontend/src/contexts/PermissionsContext.tsx | 121 +++ frontend/src/contexts/index.ts | 3 +- .../settings/tabs/ImageSecurityTab.tsx | 4 +- frontend/src/hooks/index.ts | 3 +- frontend/src/hooks/usePermission.ts | 23 + frontend/src/i18n/locales/de.json | 163 ++- frontend/src/i18n/locales/en.json | 163 ++- .../src/pages/admin/UserManagementPage.tsx | 973 ++++++++++++++++++ frontend/src/pages/admin/index.ts | 3 +- .../src/pages/public/AcceptInvitePage.tsx | 559 ++++++++++ frontend/src/services/index.ts | 3 +- .../src/services/userManagement.service.ts | 187 ++++ frontend/src/types/index.ts | 39 + 65 files changed, 4854 insertions(+), 493 deletions(-) delete mode 100644 backend/data/photo_sharing.db create mode 100644 backend/migrations/core/054_add_roles_table.js create mode 100644 backend/migrations/core/055_add_permissions_table.js create mode 100644 backend/migrations/core/056_add_role_permissions_table.js create mode 100644 backend/migrations/core/057_add_role_to_admin_users.js create mode 100644 backend/migrations/core/058_add_admin_invitations_table.js create mode 100644 backend/migrations/core/059_add_admin_email_templates.js create mode 100644 backend/migrations/core/060_add_events_created_by.js create mode 100644 backend/src/middleware/permissions.js create mode 100644 backend/src/routes/acceptInvite.js create mode 100644 backend/src/routes/adminUsers.js create mode 100644 backend/src/services/userManagementService.js create mode 100644 frontend/src/components/admin/PermissionGate.tsx create mode 100644 frontend/src/contexts/PermissionsContext.tsx create mode 100644 frontend/src/hooks/usePermission.ts create mode 100644 frontend/src/pages/admin/UserManagementPage.tsx create mode 100644 frontend/src/pages/public/AcceptInvitePage.tsx create mode 100644 frontend/src/services/userManagement.service.ts 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 2a0b50034e0bb1e6d659951866cae379014549e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 565248 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 { + 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:

+ + + +
+

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:

+ + + +
+

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:

+
    +
  • 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, click the button below:

+ + + +

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

+ +

Best regards,
+The PicPeak Team

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

+
    +
  • 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, klicken Sie auf die Schaltflache unten:

+ + + +

Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.

+ +

Mit freundlichen Grussen,
+Ihr PicPeak-Team

`, + 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';