diff --git a/.gitignore b/.gitignore
index fd3844d9..46f90243 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,9 +81,14 @@ CLAUDE.md
BUGS_AND_FEATURES.md
frontend/TEST_PLAN.md
docs/REFACTORING_PLAN.md
+docs/MULTIPLE_ADMINISTRATORS_PLAN.md
+docs/*_PLAN.md
docs/test-*.md
docs/feature-*.md
+# Local backup directory (from testing)
+backup/
+
# Local artifacts from browser tooling
.playwright-mcp/
diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md
index aa5122e0..9d13a68d 100644
--- a/DEPLOYMENT_GUIDE.md
+++ b/DEPLOYMENT_GUIDE.md
@@ -450,6 +450,20 @@ ADMIN_EMAIL=your-email@yourdomain.com
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
+### Routing Schema
+
+PicPeak consists of two services that need to be routed correctly:
+
+| Path | Service | Port | Description |
+|------|---------|------|-------------|
+| `/api/*` | Backend | 3001 | All API endpoints |
+| `/photos/*` | Backend | 3001 | Protected photo files |
+| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
+| `/uploads/*` | Backend | 3001 | Upload files |
+| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
+
+> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
+
### Option 1: Nginx
Install nginx and create `/etc/nginx/sites-available/picpeak`:
@@ -468,39 +482,32 @@ server {
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
- # Frontend
- location / {
- proxy_pass http://localhost:3000;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
-
- # Frontend (serves UI and /admin/*)
- location / {
- proxy_pass http://localhost:3000;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
-
- # Backend API and protected resources
- location /api {
+ # Backend: API endpoints
+ location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
- location ~ ^/(photos|thumbnails|uploads) {
+
+ # Backend: Protected media files
+ location ~ ^/(photos|thumbnails|uploads)/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
+
+ # Frontend: Everything else (React SPA)
+ location / {
+ proxy_pass http://localhost:3000;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
}
```
@@ -530,10 +537,16 @@ services:
backend:
labels:
- "traefik.enable=true"
+ # API endpoints
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
+ # Protected media files
+ - "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
+ - "traefik.http.routers.picpeak-media.entrypoints=websecure"
+ - "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
+ - "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
```
### Option 3: Caddy
@@ -542,21 +555,12 @@ Create a `Caddyfile`:
```caddyfile
your-domain.com {
- # Frontend
- handle /* {
- reverse_proxy localhost:3000
- }
-
- # Backend API and admin
+ # Backend: API endpoints
handle /api/* {
reverse_proxy localhost:3001
}
-
- handle /admin/* {
- reverse_proxy localhost:3001
- }
- # Protected resources
+ # Backend: Protected media files
handle /photos/* {
reverse_proxy localhost:3001
}
@@ -568,6 +572,11 @@ your-domain.com {
handle /uploads/* {
reverse_proxy localhost:3001
}
+
+ # Frontend: Everything else (React SPA including /admin/*, /gallery/*)
+ handle {
+ reverse_proxy localhost:3000
+ }
}
```
diff --git a/backend/Dockerfile b/backend/Dockerfile
index fc4e37d5..3a09f01e 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -12,7 +12,8 @@ LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
-RUN npm install -g npm@latest
+# Pin to npm 10.x which supports --omit=dev flag
+RUN npm install -g npm@10
WORKDIR /app
@@ -34,7 +35,8 @@ WORKDIR /app
RUN apk upgrade --no-cache
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
-RUN npm install -g npm@latest
+# Pin to npm 10.x which supports --omit=dev flag
+RUN npm install -g npm@10
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
@@ -46,8 +48,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .
-# Make wait script executable
-RUN chmod +x wait-for-db.sh
+# Ensure all source files are readable and wait script is executable
+RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db
deleted file mode 100644
index 2a0b5003..00000000
Binary files a/backend/data/photo_sharing.db and /dev/null differ
diff --git a/backend/migrations/core/054_add_roles_table.js b/backend/migrations/core/054_add_roles_table.js
new file mode 100644
index 00000000..28026ed0
--- /dev/null
+++ b/backend/migrations/core/054_add_roles_table.js
@@ -0,0 +1,91 @@
+/**
+ * Migration: Add Roles Table
+ * Creates the roles table for RBAC multi-administrator support.
+ *
+ * Default roles:
+ * - super_admin (priority 100): Full system access including user management
+ * - admin (priority 80): Full event and photo management
+ * - editor (priority 50): Can edit events and photos but not create or delete
+ * - viewer (priority 20): Read-only access to dashboard and events
+ */
+
+exports.up = async function(knex) {
+ console.log('Creating roles table...');
+
+ // Check if table already exists
+ const hasRolesTable = await knex.schema.hasTable('roles');
+
+ if (!hasRolesTable) {
+ await knex.schema.createTable('roles', (table) => {
+ table.increments('id').primary();
+ table.string('name', 50).unique().notNullable(); // 'super_admin', 'admin', 'editor', 'viewer'
+ table.string('display_name', 100).notNullable(); // 'Super Admin', 'Admin', etc.
+ table.text('description');
+ table.boolean('is_system').defaultTo(false); // System roles cannot be deleted
+ table.integer('priority').defaultTo(0); // Higher = more privileged (for hierarchy)
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+
+ // Index for name lookups
+ table.index(['name']);
+ // Index for priority-based ordering
+ table.index(['priority']);
+ });
+
+ console.log('Roles table created');
+ }
+
+ // Insert default system roles
+ const existingRoles = await knex('roles').select('name');
+ const existingRoleNames = existingRoles.map(r => r.name);
+
+ const defaultRoles = [
+ {
+ name: 'super_admin',
+ display_name: 'Super Admin',
+ description: 'Full system access including user management',
+ is_system: true,
+ priority: 100
+ },
+ {
+ name: 'admin',
+ display_name: 'Admin',
+ description: 'Full event and photo management',
+ is_system: true,
+ priority: 80
+ },
+ {
+ name: 'editor',
+ display_name: 'Editor',
+ description: 'Can edit events and photos but not create or delete',
+ is_system: true,
+ priority: 50
+ },
+ {
+ name: 'viewer',
+ display_name: 'Viewer',
+ description: 'Read-only access to dashboard and events',
+ is_system: true,
+ priority: 20
+ }
+ ];
+
+ const rolesToInsert = defaultRoles.filter(role => !existingRoleNames.includes(role.name));
+
+ if (rolesToInsert.length > 0) {
+ await knex('roles').insert(rolesToInsert);
+ console.log(`Inserted ${rolesToInsert.length} default roles`);
+ }
+
+ console.log('Roles table migration completed successfully');
+};
+
+exports.down = async function(knex) {
+ console.log('Removing roles table...');
+
+ // Note: This will fail if there are foreign key references
+ // The role_permissions and admin_users tables must be rolled back first
+ await knex.schema.dropTableIfExists('roles');
+
+ console.log('Roles table removed');
+};
diff --git a/backend/migrations/core/055_add_permissions_table.js b/backend/migrations/core/055_add_permissions_table.js
new file mode 100644
index 00000000..c9367734
--- /dev/null
+++ b/backend/migrations/core/055_add_permissions_table.js
@@ -0,0 +1,122 @@
+/**
+ * Migration: Add Permissions Table
+ * Creates the permissions table for granular access control.
+ *
+ * Permission categories:
+ * - events: View, create, edit, delete, archive events
+ * - photos: View, upload, edit, delete, download photos
+ * - archives: View, restore, download, delete archives
+ * - analytics: View analytics and statistics
+ * - email: View, edit, send emails
+ * - branding: View and edit branding settings
+ * - cms: View and edit CMS pages
+ * - settings: View and edit application settings
+ * - backup: View, create, restore, delete backups
+ * - users: View, create, edit, delete admin users (Super Admin only)
+ * - activity: View and export activity logs
+ */
+
+exports.up = async function(knex) {
+ console.log('Creating permissions table...');
+
+ // Check if table already exists
+ const hasPermissionsTable = await knex.schema.hasTable('permissions');
+
+ if (!hasPermissionsTable) {
+ await knex.schema.createTable('permissions', (table) => {
+ table.increments('id').primary();
+ table.string('name', 100).unique().notNullable(); // 'events.create', 'users.manage', etc.
+ table.string('display_name', 150).notNullable();
+ table.string('category', 50).notNullable(); // 'events', 'photos', 'users', 'settings'
+ table.text('description');
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+
+ // Indexes for efficient lookups
+ table.index(['name']);
+ table.index(['category']);
+ });
+
+ console.log('Permissions table created');
+ }
+
+ // Check for existing permissions
+ const existingPermissions = await knex('permissions').select('name');
+ const existingPermissionNames = existingPermissions.map(p => p.name);
+
+ // Define all permissions
+ const permissions = [
+ // Events
+ { name: 'events.view', display_name: 'View Events', category: 'events', description: 'View event list and details' },
+ { name: 'events.create', display_name: 'Create Events', category: 'events', description: 'Create new events' },
+ { name: 'events.edit', display_name: 'Edit Events', category: 'events', description: 'Edit existing events' },
+ { name: 'events.delete', display_name: 'Delete Events', category: 'events', description: 'Delete events' },
+ { name: 'events.archive', display_name: 'Archive Events', category: 'events', description: 'Archive and restore events' },
+
+ // Photos
+ { name: 'photos.view', display_name: 'View Photos', category: 'photos', description: 'View photos in events' },
+ { name: 'photos.upload', display_name: 'Upload Photos', category: 'photos', description: 'Upload photos to events' },
+ { name: 'photos.edit', display_name: 'Edit Photos', category: 'photos', description: 'Edit photo metadata and categories' },
+ { name: 'photos.delete', display_name: 'Delete Photos', category: 'photos', description: 'Delete photos from events' },
+ { name: 'photos.download', display_name: 'Download Photos', category: 'photos', description: 'Download photos and bulk export' },
+
+ // Archives
+ { name: 'archives.view', display_name: 'View Archives', category: 'archives', description: 'View archived events' },
+ { name: 'archives.restore', display_name: 'Restore Archives', category: 'archives', description: 'Restore archived events' },
+ { name: 'archives.download', display_name: 'Download Archives', category: 'archives', description: 'Download archive files' },
+ { name: 'archives.delete', display_name: 'Delete Archives', category: 'archives', description: 'Permanently delete archives' },
+
+ // Analytics
+ { name: 'analytics.view', display_name: 'View Analytics', category: 'analytics', description: 'View analytics and statistics' },
+
+ // Email
+ { name: 'email.view', display_name: 'View Email Settings', category: 'email', description: 'View email configuration' },
+ { name: 'email.edit', display_name: 'Edit Email Settings', category: 'email', description: 'Configure email settings and templates' },
+ { name: 'email.send', display_name: 'Send Emails', category: 'email', description: 'Send and resend gallery emails' },
+
+ // Branding & CMS
+ { name: 'branding.view', display_name: 'View Branding', category: 'branding', description: 'View branding settings' },
+ { name: 'branding.edit', display_name: 'Edit Branding', category: 'branding', description: 'Edit branding and theme settings' },
+ { name: 'cms.view', display_name: 'View CMS Pages', category: 'cms', description: 'View CMS content pages' },
+ { name: 'cms.edit', display_name: 'Edit CMS Pages', category: 'cms', description: 'Edit CMS content pages' },
+
+ // Settings
+ { name: 'settings.view', display_name: 'View Settings', category: 'settings', description: 'View application settings' },
+ { name: 'settings.edit', display_name: 'Edit Settings', category: 'settings', description: 'Modify application settings' },
+
+ // Backup
+ { name: 'backup.view', display_name: 'View Backups', category: 'backup', description: 'View backup status and history' },
+ { name: 'backup.create', display_name: 'Create Backups', category: 'backup', description: 'Create new backups' },
+ { name: 'backup.restore', display_name: 'Restore Backups', category: 'backup', description: 'Restore from backups' },
+ { name: 'backup.delete', display_name: 'Delete Backups', category: 'backup', description: 'Delete backup files' },
+
+ // User Management (Super Admin only)
+ { name: 'users.view', display_name: 'View Users', category: 'users', description: 'View admin user list' },
+ { name: 'users.create', display_name: 'Create Users', category: 'users', description: 'Invite new admin users' },
+ { name: 'users.edit', display_name: 'Edit Users', category: 'users', description: 'Edit admin user details and roles' },
+ { name: 'users.delete', display_name: 'Delete Users', category: 'users', description: 'Deactivate or delete admin users' },
+
+ // Activity Logs
+ { name: 'activity.view', display_name: 'View Activity Logs', category: 'activity', description: 'View system activity logs' },
+ { name: 'activity.export', display_name: 'Export Activity Logs', category: 'activity', description: 'Export activity logs' }
+ ];
+
+ // Filter out already existing permissions
+ const permissionsToInsert = permissions.filter(p => !existingPermissionNames.includes(p.name));
+
+ if (permissionsToInsert.length > 0) {
+ await knex('permissions').insert(permissionsToInsert);
+ console.log(`Inserted ${permissionsToInsert.length} permissions`);
+ }
+
+ console.log('Permissions table migration completed successfully');
+};
+
+exports.down = async function(knex) {
+ console.log('Removing permissions table...');
+
+ // Note: This will fail if there are foreign key references
+ // The role_permissions table must be rolled back first
+ await knex.schema.dropTableIfExists('permissions');
+
+ console.log('Permissions table removed');
+};
diff --git a/backend/migrations/core/056_add_role_permissions_table.js b/backend/migrations/core/056_add_role_permissions_table.js
new file mode 100644
index 00000000..8288558f
--- /dev/null
+++ b/backend/migrations/core/056_add_role_permissions_table.js
@@ -0,0 +1,134 @@
+/**
+ * Migration: Add Role Permissions Junction Table
+ * Creates the junction table mapping permissions to roles.
+ *
+ * Role permission mappings:
+ * - super_admin: All permissions
+ * - admin: Events, Photos, Archives, Analytics, Email, Branding, CMS, Settings (view), Backup (view/create), Activity (view)
+ * - editor: View/Create/Edit own events and photos, Analytics (view), Activity (view)
+ * - viewer: View-only access to events, photos, archives, analytics, branding, cms
+ */
+
+exports.up = async function(knex) {
+ console.log('Creating role_permissions junction table...');
+
+ // Check if table already exists
+ const hasRolePermissionsTable = await knex.schema.hasTable('role_permissions');
+
+ if (!hasRolePermissionsTable) {
+ await knex.schema.createTable('role_permissions', (table) => {
+ table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE');
+ table.integer('permission_id').unsigned().references('id').inTable('permissions').onDelete('CASCADE');
+ table.primary(['role_id', 'permission_id']);
+
+ // Indexes for efficient lookups
+ table.index(['role_id']);
+ table.index(['permission_id']);
+ });
+
+ console.log('Role permissions junction table created');
+ }
+
+ // Get role and permission IDs
+ const roles = await knex('roles').select('id', 'name');
+ const permissions = await knex('permissions').select('id', 'name');
+
+ if (roles.length === 0 || permissions.length === 0) {
+ console.log('No roles or permissions found, skipping permission mappings');
+ return;
+ }
+
+ const roleMap = Object.fromEntries(roles.map(r => [r.name, r.id]));
+ const permMap = Object.fromEntries(permissions.map(p => [p.name, p.id]));
+
+ // Define role-permission mappings
+ const rolePermissions = {
+ super_admin: permissions.map(p => p.name), // All permissions
+ admin: [
+ // Events - full access
+ 'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
+ // Photos - full access
+ 'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
+ // Archives - full access
+ 'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
+ // Analytics - view only
+ 'analytics.view',
+ // Email - full access
+ 'email.view', 'email.edit', 'email.send',
+ // Branding - full access
+ 'branding.view', 'branding.edit',
+ // CMS - full access
+ 'cms.view', 'cms.edit',
+ // Settings - view only
+ 'settings.view',
+ // Backup - view and create only
+ 'backup.view', 'backup.create',
+ // Activity - view only
+ 'activity.view'
+ ],
+ editor: [
+ // Events - view, create, and edit (can only see their own events)
+ 'events.view', 'events.create', 'events.edit',
+ // Photos - view, upload, edit (no delete)
+ 'photos.view', 'photos.upload', 'photos.edit',
+ // Analytics - view only
+ 'analytics.view',
+ // Activity - view only
+ 'activity.view'
+ ],
+ viewer: [
+ // Events - view only
+ 'events.view',
+ // Photos - view only
+ 'photos.view',
+ // Archives - view only
+ 'archives.view',
+ // Analytics - view only
+ 'analytics.view',
+ // Branding - view only
+ 'branding.view',
+ // CMS - view only
+ 'cms.view'
+ ]
+ };
+
+ // Check for existing mappings to avoid duplicates
+ const existingMappings = await knex('role_permissions').select('role_id', 'permission_id');
+ const existingSet = new Set(existingMappings.map(m => `${m.role_id}-${m.permission_id}`));
+
+ // Build insert list
+ const inserts = [];
+ for (const [roleName, perms] of Object.entries(rolePermissions)) {
+ for (const permName of perms) {
+ if (roleMap[roleName] && permMap[permName]) {
+ const key = `${roleMap[roleName]}-${permMap[permName]}`;
+ if (!existingSet.has(key)) {
+ inserts.push({
+ role_id: roleMap[roleName],
+ permission_id: permMap[permName]
+ });
+ }
+ }
+ }
+ }
+
+ if (inserts.length > 0) {
+ // Insert in batches to avoid hitting database limits
+ const batchSize = 50;
+ for (let i = 0; i < inserts.length; i += batchSize) {
+ const batch = inserts.slice(i, i + batchSize);
+ await knex('role_permissions').insert(batch);
+ }
+ console.log(`Inserted ${inserts.length} role-permission mappings`);
+ }
+
+ console.log('Role permissions junction table migration completed successfully');
+};
+
+exports.down = async function(knex) {
+ console.log('Removing role_permissions junction table...');
+
+ await knex.schema.dropTableIfExists('role_permissions');
+
+ console.log('Role permissions junction table removed');
+};
diff --git a/backend/migrations/core/057_add_role_to_admin_users.js b/backend/migrations/core/057_add_role_to_admin_users.js
new file mode 100644
index 00000000..c418c186
--- /dev/null
+++ b/backend/migrations/core/057_add_role_to_admin_users.js
@@ -0,0 +1,115 @@
+/**
+ * Migration: Add Role to Admin Users
+ * Adds RBAC-related columns to the admin_users table:
+ * - role_id: Foreign key to roles table
+ * - created_by: Foreign key to admin_users (who invited this user)
+ * - invite_token: Token for invitation acceptance (64 chars = 256 bits)
+ * - invite_expires_at: When the invitation token expires
+ * - invite_accepted_at: When the user accepted the invitation
+ *
+ * Also migrates existing admin users to super_admin role.
+ */
+
+exports.up = async function(knex) {
+ console.log('Adding role columns to admin_users table...');
+
+ // Check if columns already exist
+ const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
+ const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
+ const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
+ const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
+ const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
+
+ // Add new columns if they don't exist
+ if (!hasRoleId || !hasCreatedBy || !hasInviteToken || !hasInviteExpiresAt || !hasInviteAcceptedAt) {
+ await knex.schema.alterTable('admin_users', (table) => {
+ if (!hasRoleId) {
+ // Note: We add as nullable first, then set values, then alter to not null
+ table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL');
+ }
+ if (!hasCreatedBy) {
+ table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
+ }
+ if (!hasInviteToken) {
+ // 64 characters = 32 bytes hex = 256 bits of entropy (cryptographically secure)
+ table.string('invite_token', 64);
+ }
+ if (!hasInviteExpiresAt) {
+ table.timestamp('invite_expires_at');
+ }
+ if (!hasInviteAcceptedAt) {
+ table.timestamp('invite_accepted_at');
+ }
+ });
+
+ console.log('Role columns added to admin_users table');
+ }
+
+ // Add index on invite_token for fast lookup
+ const hasInviteTokenIndex = await knex.schema.hasColumn('admin_users', 'invite_token');
+ if (hasInviteTokenIndex) {
+ // Create index if it doesn't exist (safe for both PostgreSQL and SQLite)
+ try {
+ await knex.schema.alterTable('admin_users', (table) => {
+ table.index(['invite_token']);
+ });
+ } catch (e) {
+ // Index may already exist
+ if (!e.message.includes('already exists')) {
+ console.log('Note: invite_token index may already exist');
+ }
+ }
+ }
+
+ // Get super_admin role ID
+ const superAdminRole = await knex('roles').where('name', 'super_admin').first();
+
+ if (superAdminRole) {
+ // Migrate existing admin users without a role to super_admin
+ const usersWithoutRole = await knex('admin_users')
+ .whereNull('role_id')
+ .select('id');
+
+ if (usersWithoutRole.length > 0) {
+ await knex('admin_users')
+ .whereNull('role_id')
+ .update({ role_id: superAdminRole.id });
+
+ console.log(`Migrated ${usersWithoutRole.length} existing admin user(s) to super_admin role`);
+ }
+ } else {
+ console.log('Warning: super_admin role not found. Run migration 054 first.');
+ }
+
+ console.log('Admin users role migration completed successfully');
+};
+
+exports.down = async function(knex) {
+ console.log('Removing role columns from admin_users table...');
+
+ const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
+ const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
+ const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
+ const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
+ const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
+
+ await knex.schema.alterTable('admin_users', (table) => {
+ if (hasInviteAcceptedAt) {
+ table.dropColumn('invite_accepted_at');
+ }
+ if (hasInviteExpiresAt) {
+ table.dropColumn('invite_expires_at');
+ }
+ if (hasInviteToken) {
+ table.dropColumn('invite_token');
+ }
+ if (hasCreatedBy) {
+ table.dropColumn('created_by');
+ }
+ if (hasRoleId) {
+ table.dropColumn('role_id');
+ }
+ });
+
+ console.log('Role columns removed from admin_users table');
+};
diff --git a/backend/migrations/core/058_add_admin_invitations_table.js b/backend/migrations/core/058_add_admin_invitations_table.js
new file mode 100644
index 00000000..9b97c7bd
--- /dev/null
+++ b/backend/migrations/core/058_add_admin_invitations_table.js
@@ -0,0 +1,68 @@
+/**
+ * Migration: Add Admin Invitations Table
+ * Creates the admin_invitations table for managing pending admin user invitations.
+ *
+ * Security features:
+ * - Token is 64 characters (32 bytes hex = 256 bits of entropy)
+ * - Tokens are unique and indexed for fast lookup
+ * - Invitations have expiration timestamps
+ * - Tracks who invited whom and when accepted
+ * - Foreign key constraints with appropriate CASCADE behavior
+ */
+
+exports.up = async function(knex) {
+ console.log('Creating admin_invitations table...');
+
+ // Check if table already exists
+ const hasAdminInvitationsTable = await knex.schema.hasTable('admin_invitations');
+
+ if (!hasAdminInvitationsTable) {
+ await knex.schema.createTable('admin_invitations', (table) => {
+ table.increments('id').primary();
+
+ // Email of the invited user
+ table.string('email', 255).notNullable();
+
+ // Invitation token - 64 characters = 32 bytes hex = 256 bits of entropy
+ // Cryptographically secure for one-time use tokens
+ table.string('token', 64).unique().notNullable();
+
+ // Role to assign when invitation is accepted
+ table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE').notNullable();
+
+ // Who created this invitation
+ table.integer('invited_by').unsigned().references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
+
+ // When the invitation expires (typically 7 days from creation)
+ table.timestamp('expires_at').notNullable();
+
+ // When the invitation was accepted (null if pending)
+ table.timestamp('accepted_at');
+
+ // The admin_user ID created when invitation was accepted (for audit trail)
+ table.integer('accepted_user_id').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
+
+ // When the invitation was created
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+
+ // Indexes for efficient lookups
+ table.index(['token']); // Fast token validation
+ table.index(['email']); // Check for existing invitations by email
+ table.index(['expires_at']); // Cleanup expired invitations
+ table.index(['invited_by']); // List invitations by inviter
+ table.index(['accepted_at']); // Filter pending vs accepted
+ });
+
+ console.log('Admin invitations table created');
+ }
+
+ console.log('Admin invitations table migration completed successfully');
+};
+
+exports.down = async function(knex) {
+ console.log('Removing admin_invitations table...');
+
+ await knex.schema.dropTableIfExists('admin_invitations');
+
+ console.log('Admin invitations table removed');
+};
diff --git a/backend/migrations/core/059_add_admin_email_templates.js b/backend/migrations/core/059_add_admin_email_templates.js
new file mode 100644
index 00000000..bd25b1db
--- /dev/null
+++ b/backend/migrations/core/059_add_admin_email_templates.js
@@ -0,0 +1,239 @@
+/**
+ * Migration to add email templates for admin invitation and password reset
+ * These templates support the RBAC (Role-Based Access Control) feature
+ */
+exports.up = async function(knex) {
+ // Check which templates already exist
+ const existingTemplates = await knex('email_templates')
+ .select('template_key')
+ .whereIn('template_key', ['admin_invitation', 'admin_password_reset']);
+
+ const existingKeys = existingTemplates.map(t => t.template_key);
+
+ // Admin Invitation Email Template
+ if (!existingKeys.includes('admin_invitation')) {
+ await knex('email_templates').insert({
+ template_key: 'admin_invitation',
+ subject_en: 'You have been invited to join PicPeak as {{role_name}}',
+ subject_de: 'Sie wurden eingeladen, PicPeak als {{role_name}} beizutreten',
+ body_html_en: `
+
Welcome to PicPeak!
+
+You have been invited to join the PicPeak photo sharing platform as a {{role_name}} .
+
+
+
Your Role: {{role_name}}
+
This role grants you access to manage and administer the photo sharing platform.
+
+
+To accept this invitation and set up your account, click the button below:
+
+
+
+
+
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 10559235..dd9547be 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..5dcbbd6b 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(),
@@ -569,7 +581,8 @@ router.put('/:id', adminAuth, [
throw new Error('Password must be at least 6 characters long');
}
return true;
- })
+ }),
+ body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -659,7 +672,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 +714,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 +795,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 +835,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 +902,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 +985,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 +1016,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..8f13e84b 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();
@@ -176,21 +177,23 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Parse category_id to number if provided
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
-
- // Determine photo type from category_id parameter (for backwards compatibility)
+
+ // Determine photo type and category name
let photoType = 'individual'; // default
let categoryName = 'individual';
-
- if (parsedCategoryId === 1 || category_id === 'collage') {
- photoType = 'collage';
- categoryName = 'collages';
- } else if (parsedCategoryId === 2 || category_id === 'individual') {
- photoType = 'individual';
- categoryName = 'individual';
- }
-
- // For backwards compatibility, accept string values
- if (category_id === 'collage') {
+
+ // Look up the actual category from database if provided
+ if (parsedCategoryId && !isNaN(parsedCategoryId)) {
+ const category = await db('photo_categories').where({ id: parsedCategoryId }).first();
+ if (category) {
+ categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
+ // Use category slug for type determination
+ if (category.slug === 'collage' || category.slug === 'collages') {
+ photoType = 'collage';
+ }
+ }
+ } else if (category_id === 'collage') {
+ // For backwards compatibility, accept string values
photoType = 'collage';
categoryName = 'collages';
}
@@ -256,6 +259,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
path: relativePath,
thumbnail_path: null, // Will generate after successful commit
type: photoType,
+ category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size // Use actual file size from stat
};
@@ -420,7 +424,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 +481,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;
@@ -517,7 +521,15 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
.where({ id: photoId, event_id: eventId })
.update(updateData);
- res.json({ message: 'Photo updated successfully' });
+ // Fetch and return the updated photo
+ const updatedPhoto = await db('photos')
+ .where({ id: photoId, event_id: eventId })
+ .first();
+
+ res.json({
+ message: 'Photo updated successfully',
+ photo: updatedPhoto
+ });
} catch (error) {
console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' });
@@ -525,7 +537,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 +605,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 +663,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,14 +695,15 @@ 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;
let query = db('photos')
.where({ 'photos.event_id': eventId })
- .select('photos.*');
+ .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
+ .select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
// Filter by type (individual/collage) - category_id maps to type
if (category_id !== undefined) {
@@ -747,9 +760,9 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
// Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
- category_id: photo.type,
- category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
- category_slug: photo.type,
+ category_id: photo.category_id || photo.type,
+ category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
+ category_slug: photo.pc_slug || photo.type,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Feedback data
@@ -767,7 +780,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 +817,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 +857,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 +883,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 +921,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 +942,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 +984,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 +1002,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 1bc72629..c8342f95 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -30,13 +30,13 @@ COPY . .
# Build the application
RUN npm run build
-# Production stage (use Alpine with patched libpng)
-FROM nginx:1.27-alpine3.21
+# Production stage (use Alpine with patched libpng/c-ares)
+FROM nginx:1.27-alpine
-# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
+# 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)
+# and c-ares >= 1.34.5 (fixes CVE-2025-31498)
RUN apk upgrade --no-cache
-# Ensure libpng includes CVE fixes (pull patched version from edge)
-RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main 'libpng>=1.6.51-r0'
# Install runtime dependencies
RUN apk add --no-cache curl
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 */}
- {navigation.map((item) => {
+ {filteredNavigation.map((item) => {
const isActive = location.pathname === item.href ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
@@ -90,14 +101,16 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose }) =
})}
- {/* 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/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
index ba14e9f0..fffb70d5 100644
--- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
+++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
-import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info } from 'lucide-react';
+import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
+import type { EnabledTemplate } from '../../services/cssTemplates.service';
// import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -16,6 +17,10 @@ interface ThemeCustomizerEnhancedProps {
hideActions?: boolean;
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise | void;
isApplying?: boolean;
+ // CSS Template props
+ cssTemplates?: EnabledTemplate[];
+ cssTemplateId?: number | null;
+ onCssTemplateChange?: (templateId: number | null) => void;
}
const layoutIcons: Record = {
@@ -38,7 +43,10 @@ export const ThemeCustomizerEnhanced: React.FC = (
showGalleryLayouts = true,
hideActions = false,
onApply,
- isApplying = false
+ isApplying = false,
+ cssTemplates,
+ cssTemplateId,
+ onCssTemplateChange
}) => {
const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState(value);
@@ -566,11 +574,67 @@ export const ThemeCustomizerEnhanced: React.FC = (
- {/* Custom CSS */}
+ {/* CSS Template Selector - only show if templates are provided */}
+ {cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
+
+
+
+ {t('branding.cssTemplate', 'CSS Template')}
+
+
+ {t('branding.cssTemplateDescription', 'Select a pre-built CSS template to apply application-wide styling to this gallery. Templates can be managed in Settings > CSS Templates.')}
+
+
+ {/* No template option */}
+
onCssTemplateChange(null)}
+ className={`relative p-4 rounded-lg border-2 transition-all text-left ${
+ !cssTemplateId
+ ? 'border-primary-600 bg-primary-50'
+ : 'border-neutral-200 hover:border-neutral-300'
+ }`}
+ >
+
+ {t('branding.noTemplate', 'No Template')}
+ {!cssTemplateId && (
+
+ )}
+
+
+ {t('branding.noTemplateDescription', 'Use only theme settings without a CSS template')}
+
+
+ {/* Template options */}
+ {cssTemplates.map((template) => (
+
onCssTemplateChange(template.id)}
+ className={`relative p-4 rounded-lg border-2 transition-all text-left ${
+ cssTemplateId === template.id
+ ? 'border-primary-600 bg-primary-50'
+ : 'border-neutral-200 hover:border-neutral-300'
+ }`}
+ >
+
+ {template.name}
+ {cssTemplateId === template.id && (
+
+ )}
+
+
+ {t('branding.templateSlot', 'Slot {{slot}}', { slot: template.slot_number })}
+
+
+ ))}
+
+
+ )}
+
+ {/* Event-specific Custom CSS */}
- {t('branding.customCSS')}
+ {t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
{/* Collapsible Instructions Panel */}
diff --git a/frontend/src/components/admin/ThemeEditorModal.tsx b/frontend/src/components/admin/ThemeEditorModal.tsx
index 85bfe7e5..ecd1f904 100644
--- a/frontend/src/components/admin/ThemeEditorModal.tsx
+++ b/frontend/src/components/admin/ThemeEditorModal.tsx
@@ -4,13 +4,15 @@ import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
+import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { useTranslation } from 'react-i18next';
interface ThemeEditorModalProps {
isOpen: boolean;
onClose: () => void;
- onSave: (theme: ThemeConfig, presetName: string) => void;
+ onSave: (theme: ThemeConfig, presetName: string, cssTemplateId: number | null) => void;
currentTheme: ThemeConfig | string;
+ currentCssTemplateId?: number | null;
eventName: string;
}
@@ -28,12 +30,29 @@ export const ThemeEditorModal: React.FC = ({
onClose,
onSave,
currentTheme,
+ currentCssTemplateId,
eventName
}) => {
const { t } = useTranslation();
const [theme, setTheme] = useState(GALLERY_THEME_PRESETS.default.config);
const [presetName, setPresetName] = useState('default');
const [previewLayout, setPreviewLayout] = useState(undefined);
+ const [cssTemplates, setCssTemplates] = useState([]);
+ const [cssTemplateId, setCssTemplateId] = useState(currentCssTemplateId ?? null);
+
+ // Fetch CSS templates when modal opens
+ useEffect(() => {
+ if (isOpen) {
+ cssTemplatesService.getEnabledTemplates()
+ .then(setCssTemplates)
+ .catch(err => console.error('Failed to load CSS templates:', err));
+ }
+ }, [isOpen]);
+
+ // Update cssTemplateId when prop changes
+ useEffect(() => {
+ setCssTemplateId(currentCssTemplateId ?? null);
+ }, [currentCssTemplateId]);
useEffect(() => {
if (currentTheme) {
@@ -82,7 +101,7 @@ export const ThemeEditorModal: React.FC = ({
};
const handleSave = () => {
- onSave(theme, presetName);
+ onSave(theme, presetName, cssTemplateId);
onClose();
};
@@ -128,6 +147,9 @@ export const ThemeEditorModal: React.FC = ({
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
+ cssTemplates={cssTemplates}
+ cssTemplateId={cssTemplateId}
+ onCssTemplateChange={setCssTemplateId}
/>
diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx
index 707f47a3..c6ceb8f6 100644
--- a/frontend/src/components/gallery/PhotoLightbox.tsx
+++ b/frontend/src/components/gallery/PhotoLightbox.tsx
@@ -456,7 +456,7 @@ export const PhotoLightbox: React.FC = ({
onClick={() => {
setShowFeedback(!showFeedback);
}}
- className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
+ className="relative p-2 bg-black/40 hover:bg-black/60 rounded-full border border-white/40 transition-colors"
aria-label="Toggle feedback"
title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`}
>
diff --git a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx
index 8853934c..9fc6508c 100644
--- a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx
+++ b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx
@@ -164,7 +164,7 @@ export const CarouselGalleryLayout: React.FC = ({
});
} catch (_) {}
}}
- className={`hover:bg-white/20 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
+ className={`bg-black/30 hover:bg-black/50 rounded-full border border-white/40 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
title="Like photo"
aria-pressed={likedIds.has(currentPhoto.id)}
>
@@ -176,7 +176,7 @@ export const CarouselGalleryLayout: React.FC = ({
variant="ghost"
size="sm"
onClick={() => { onOpenPhotoWithFeedback?.(currentIndex); }}
- className="text-white hover:bg-white/20"
+ className="text-white bg-black/30 hover:bg-black/50 rounded-full border border-white/40"
title="Comment"
aria-label="Comment on photo"
>
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/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index e501e6e3..753fc5e6 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -58,6 +58,7 @@ import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../services/photos.service';
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
+import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
const resolveShareLink = (link: string): string => {
@@ -141,6 +142,7 @@ export const EventDetailsPage: React.FC = () => {
type EditFormState = {
welcome_message: string;
color_theme: string;
+ css_template_id: number | null;
expires_at: string;
allow_user_uploads: boolean;
upload_category_id: number | null;
@@ -164,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
const [editForm, setEditForm] = useState({
welcome_message: '',
color_theme: '',
+ css_template_id: null,
expires_at: '',
allow_user_uploads: false,
upload_category_id: null,
@@ -207,7 +210,17 @@ export const EventDetailsPage: React.FC = () => {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [currentTheme, setCurrentTheme] = useState(null);
const [currentPresetName, setCurrentPresetName] = useState('default');
-
+ const [cssTemplates, setCssTemplates] = useState([]);
+
+ // Fetch CSS templates when component mounts or editing starts
+ useEffect(() => {
+ if (isEditing) {
+ cssTemplatesService.getEnabledTemplates()
+ .then(setCssTemplates)
+ .catch(err => console.error('Failed to load CSS templates:', err));
+ }
+ }, [isEditing]);
+
// Photo filters state
const [photoFilters, setPhotoFilters] = useState({
category_id: undefined as number | null | undefined,
@@ -375,6 +388,7 @@ export const EventDetailsPage: React.FC = () => {
setEditForm({
welcome_message: event.welcome_message || '',
color_theme: event.color_theme || '',
+ css_template_id: event.css_template_id || null,
expires_at: format(safeParseDate(event.expires_at), 'yyyy-MM-dd'),
allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null,
@@ -474,6 +488,7 @@ export const EventDetailsPage: React.FC = () => {
expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
+ css_template_id: editForm.css_template_id,
// Download protection settings
protection_level: editForm.protection_level,
disable_right_click: editForm.disable_right_click,
@@ -1357,6 +1372,9 @@ export const EventDetailsPage: React.FC = () => {
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
+ cssTemplates={cssTemplates}
+ cssTemplateId={editForm.css_template_id}
+ onCssTemplateChange={(templateId) => setEditForm(prev => ({ ...prev, css_template_id: templateId }))}
/>
)}
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')}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// 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 (
+
+
+
+
+
+
+
+ {t('common.cancel')}
+
+
+ {confirmText}
+
+
+
+
+
+ );
+};
+
+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')}
+
window.location.reload()} className="mt-4">
+ {t('common.tryAgain')}
+
+
+
+ );
+ }
+
+ 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')}
+
+
}
+ onClick={() => setShowCreateInvitationModal(true)}
+ >
+ {t('userManagement.inviteUser')}
+
+
+
+ {/* 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 */}
+
+
+ {tabs.map((tab) => (
+ setActiveTab(tab.key)}
+ className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
+ activeTab === tab.key
+ ? 'border-primary-600 text-primary-600'
+ : 'border-transparent text-neutral-500 hover:text-neutral-700'
+ }`}
+ >
+ {tab.label}
+
+ {tab.count}
+
+
+ ))}
+
+
+
+ {/* Search */}
+
+
+
+ }
+ value={searchTerm}
+ onChange={(e) => setSearchTerm(e.target.value)}
+ />
+
+
+
+
+ {/* Users Tab Content */}
+ {activeTab === 'users' && (
+
+
+
+
+
+
+ {t('userManagement.table.user')}
+
+
+ {t('userManagement.table.role')}
+
+
+ {t('userManagement.table.status')}
+
+
+ {t('userManagement.table.lastLogin')}
+
+
+ {t('userManagement.table.actions')}
+
+
+
+
+ {filteredUsers.length === 0 ? (
+
+
+ {searchTerm
+ ? t('userManagement.noUsersFound')
+ : t('userManagement.noUsers')}
+
+
+ ) : (
+ filteredUsers.map((user) => (
+
+
+
+
+
+ {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')}
+
+ )}
+
+
+
+ handleEditUser(user)}
+ className="p-1.5 text-neutral-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
+ title={t('userManagement.editUser')}
+ >
+
+
+ {user.isActive && (
+ handleDeactivateUser(user)}
+ className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
+ title={t('userManagement.deactivateUser')}
+ >
+
+
+ )}
+
+
+
+ ))
+ )}
+
+
+
+
+ )}
+
+ {/* Invitations Tab Content */}
+ {activeTab === 'invitations' && (
+
+
+
+
+
+
+ {t('userManagement.table.email')}
+
+
+ {t('userManagement.table.role')}
+
+
+ {t('userManagement.table.invitedBy')}
+
+
+ {t('userManagement.table.expires')}
+
+
+ {t('userManagement.table.actions')}
+
+
+
+
+ {filteredInvitations.length === 0 ? (
+
+
+ {searchTerm
+ ? t('userManagement.noInvitationsFound')
+ : t('userManagement.noInvitations')}
+
+
+ ) : (
+ filteredInvitations.map((invitation) => {
+ const isExpired = isPast(parseISO(invitation.expiresAt));
+ return (
+
+
+
+
+
+
+
+ {invitation.email}
+
+
+
+
+
+
+ {invitation.roleName}
+
+
+
+ {invitation.invitedBy || '-'}
+
+
+
+
+ {isExpired
+ ? t('userManagement.expired')
+ : formatDistanceToNow(parseISO(invitation.expiresAt), {
+ addSuffix: true,
+ })}
+
+
+
+ handleCancelInvitation(invitation)}
+ className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
+ title={t('userManagement.cancelInvitation')}
+ >
+
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ )}
+
+ {/* 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')}
+
+
navigate('/admin/login')}
+ >
+ {t('acceptInvitation.goToLogin')}
+
+
+
+
+
+ );
+ }
+
+ // Success state - account created
+ if (acceptMutation.isSuccess) {
+ return (
+
+
+
+
+
+
+
+
+ {t('acceptInvitation.successTitle')}
+
+
+ {t('acceptInvitation.successMessage')}
+
+
+ {t('acceptInvitation.redirecting', { seconds: redirectCountdown })}
+
+
navigate('/admin/login')}
+ >
+ {t('acceptInvitation.goToLoginNow')}
+
+
+
+
+
+ );
+ }
+
+ // Form state - valid invitation
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+ {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 */}
+
+
+
+
+ {/* Footer */}
+
+
+
+ );
+};
+
+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/photos.service.ts b/frontend/src/services/photos.service.ts
index 43e8b79a..101d67da 100644
--- a/frontend/src/services/photos.service.ts
+++ b/frontend/src/services/photos.service.ts
@@ -66,8 +66,9 @@ class PhotosService {
await api.post(`/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
}
- async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise {
- await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
+ async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | string | null): Promise {
+ const response = await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
+ return response.data.photo;
}
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise {
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';