feat: Multi-administrator RBAC, CSS templates & security hardening (#78)

- Add multi-administrator support with role-based access control
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing
  - Fix photo category selection and feedback button visibility (#77)
  - Security hardening and Alpine base image upgrade
This commit is contained in:
Paul Nothaft
2026-01-07 21:53:25 +01:00
committed by GitHub
71 changed files with 5006 additions and 527 deletions
+5
View File
@@ -81,9 +81,14 @@ CLAUDE.md
BUGS_AND_FEATURES.md BUGS_AND_FEATURES.md
frontend/TEST_PLAN.md frontend/TEST_PLAN.md
docs/REFACTORING_PLAN.md docs/REFACTORING_PLAN.md
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
docs/*_PLAN.md
docs/test-*.md docs/test-*.md
docs/feature-*.md docs/feature-*.md
# Local backup directory (from testing)
backup/
# Local artifacts from browser tooling # Local artifacts from browser tooling
.playwright-mcp/ .playwright-mcp/
+41 -32
View File
@@ -450,6 +450,20 @@ [email protected]
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. 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 ### Option 1: Nginx
Install nginx and create `/etc/nginx/sites-available/picpeak`: 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 /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Frontend # Backend: API endpoints
location / { location /api/ {
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 {
proxy_pass http://localhost:3001; proxy_pass http://localhost:3001;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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_pass http://localhost:3001;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; 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: backend:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
# API endpoints
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)" - "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure" - "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt" - "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001" - "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 ### Option 3: Caddy
@@ -542,21 +555,12 @@ Create a `Caddyfile`:
```caddyfile ```caddyfile
your-domain.com { your-domain.com {
# Frontend # Backend: API endpoints
handle /* {
reverse_proxy localhost:3000
}
# Backend API and admin
handle /api/* { handle /api/* {
reverse_proxy localhost:3001 reverse_proxy localhost:3001
} }
handle /admin/* { # Backend: Protected media files
reverse_proxy localhost:3001
}
# Protected resources
handle /photos/* { handle /photos/* {
reverse_proxy localhost:3001 reverse_proxy localhost:3001
} }
@@ -568,6 +572,11 @@ your-domain.com {
handle /uploads/* { handle /uploads/* {
reverse_proxy localhost:3001 reverse_proxy localhost:3001
} }
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
handle {
reverse_proxy localhost:3000
}
} }
``` ```
+6 -4
View File
@@ -12,7 +12,8 @@ LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability # 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 WORKDIR /app
@@ -34,7 +35,8 @@ WORKDIR /app
RUN apk upgrade --no-cache RUN apk upgrade --no-cache
# Upgrade npm to fix glob CVE-2025-64756 vulnerability # 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 # Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client 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 --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . . COPY --chown=nodejs:nodejs . .
# Make wait script executable # Ensure all source files are readable and wait script is executable
RUN chmod +x wait-for-db.sh RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Create necessary directories # Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \ RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
Binary file not shown.
@@ -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');
};
@@ -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');
};
@@ -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');
};
@@ -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');
};
@@ -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');
};
@@ -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: `
<h2>Welcome to PicPeak!</h2>
<p>You have been invited to join the PicPeak photo sharing platform as a <strong>{{role_name}}</strong>.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Your Role:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">This role grants you access to manage and administer the photo sharing platform.</p>
</div>
<p>To accept this invitation and set up your account, click the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Accept Invitation</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Important:</strong> This invitation expires on <strong>{{expires_at}}</strong>. Please accept the invitation before this date.</p>
</div>
<p>If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
If the button above does not work, copy and paste this link into your browser:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Best regards,<br>
The PicPeak Team</p>`,
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: `
<h2>Willkommen bei PicPeak!</h2>
<p>Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als <strong>{{role_name}}</strong> beizutreten.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Ihre Rolle:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.</p>
</div>
<p>Um diese Einladung anzunehmen und Ihr Konto einzurichten, klicken Sie auf die Schaltflache unten:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Einladung annehmen</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Wichtig:</strong> Diese Einladung lauft am <strong>{{expires_at}}</strong> ab. Bitte nehmen Sie die Einladung vor diesem Datum an.</p>
</div>
<p>Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
Wenn die Schaltflache oben nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Mit freundlichen Grussen,<br>
Ihr PicPeak-Team</p>`,
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: `
<h2>Password Reset Notification</h2>
<p>Hello <strong>{{username}}</strong>,</p>
<p>Your administrator password for PicPeak has been reset by a system administrator.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Your New Login Credentials:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Username:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Temporary Password:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Security Notice</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>This is a temporary password. Please change it immediately after logging in.</li>
<li>Never share your password with anyone.</li>
<li>If you did not request this password reset, please contact your system administrator immediately.</li>
</ul>
</div>
<p>To log in to the admin panel, click the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Log In Now</a>
</div>
<p style="color: #666; font-size: 13px;">After logging in, navigate to your profile settings to change your password to something secure that only you know.</p>
<p>Best regards,<br>
The PicPeak Team</p>`,
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: `
<h2>Benachrichtigung uber Passwortzurucksetzung</h2>
<p>Hallo <strong>{{username}}</strong>,</p>
<p>Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Ihre neuen Anmeldedaten:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Benutzername:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Vorlaufiges Passwort:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Sicherheitshinweis</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.</li>
<li>Teilen Sie Ihr Passwort niemals mit anderen.</li>
<li>Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.</li>
</ul>
</div>
<p>Um sich im Admin-Panel anzumelden, klicken Sie auf die Schaltflache unten:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
</div>
<p style="color: #666; font-size: 13px;">Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.</p>
<p>Mit freundlichen Grussen,<br>
Ihr PicPeak-Team</p>`,
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();
};
@@ -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');
});
};
+120 -134
View File
@@ -30,6 +30,7 @@
"i18next": "25.3.2", "i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1", "joi": "^17.9.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
@@ -258,33 +259,33 @@
} }
}, },
"node_modules/@aws-sdk/client-s3": { "node_modules/@aws-sdk/client-s3": {
"version": "3.962.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.962.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.964.0.tgz",
"integrity": "sha512-I2/1McBZCcM3PfM4ck8D6gnZR3K7+yl1fGkwTq/3ThEn9tdLjNwcdgTbPfxfX6LoecLrH9Ekoo+D9nmQ0T261w==", "integrity": "sha512-mDK+3qpfHnEPXeF6D8nQkJOkOvchllQosgfxv0FK9PNBuU9WVkP8yj7y3YwH6JYTgy1ejz1Ju/YfoUbbE6m7zw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/credential-provider-node": "3.962.0", "@aws-sdk/credential-provider-node": "3.964.0",
"@aws-sdk/middleware-bucket-endpoint": "3.957.0", "@aws-sdk/middleware-bucket-endpoint": "3.957.0",
"@aws-sdk/middleware-expect-continue": "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-host-header": "3.957.0",
"@aws-sdk/middleware-location-constraint": "3.957.0", "@aws-sdk/middleware-location-constraint": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "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-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/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/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "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/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0", "@smithy/core": "^3.20.0",
"@smithy/eventstream-serde-browser": "^4.2.7", "@smithy/eventstream-serde-browser": "^4.2.7",
@@ -325,23 +326,23 @@
} }
}, },
"node_modules/@aws-sdk/client-sso": { "node_modules/@aws-sdk/client-sso": {
"version": "3.958.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.964.0.tgz",
"integrity": "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==", "integrity": "sha512-IenVyY8Io2CwBgmS22xk/H5LibmSbvLnPA9oFqLORO6Ji1Ks8z/ow+ud/ZurVjFekz3LD/uxVFX3ZKGo6N7Byw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "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-host-header": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "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/region-config-resolver": "3.957.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "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/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0", "@smithy/core": "^3.20.0",
"@smithy/fetch-http-handler": "^5.3.8", "@smithy/fetch-http-handler": "^5.3.8",
@@ -374,9 +375,9 @@
} }
}, },
"node_modules/@aws-sdk/core": { "node_modules/@aws-sdk/core": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.964.0.tgz",
"integrity": "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==", "integrity": "sha512-1gIfbt0KRxI8am1UYFcIxQ5QKb22JyN3k52sxyrKXJYC8Knn/rTUAZbYti45CfETe5PLadInGvWqClwGRlZKNg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
@@ -411,12 +412,12 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-env": { "node_modules/@aws-sdk/credential-provider-env": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.964.0.tgz",
"integrity": "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==", "integrity": "sha512-jWNSXOOBMYuxzI2rXi8x91YL07dhomyGzzh0CdaLej0LRmknmDrZcZNkVpa7Fredy1PFcmOlokwCS5PmZMN8ZQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7", "@smithy/property-provider": "^4.2.7",
"@smithy/types": "^4.11.0", "@smithy/types": "^4.11.0",
@@ -427,12 +428,12 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-http": { "node_modules/@aws-sdk/credential-provider-http": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.964.0.tgz",
"integrity": "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==", "integrity": "sha512-up7dl6vcaoXuYSwGXDvx8RnF8Lwj3jGChhyUR7krZOXLarIfUUN3ILOZnVNK5s/HnVNkEILlkdPvjhr9LVC1/Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/fetch-http-handler": "^5.3.8", "@smithy/fetch-http-handler": "^5.3.8",
"@smithy/node-http-handler": "^4.4.7", "@smithy/node-http-handler": "^4.4.7",
@@ -448,19 +449,19 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-ini": { "node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.962.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.962.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.964.0.tgz",
"integrity": "sha512-h0kVnXLW2d3nxbcrR/Pfg3W/+YoCguasWz7/3nYzVqmdKarGrpJzaFdoZtLgvDSZ8VgWUC4lWOTcsDMV0UNqUQ==", "integrity": "sha512-t4FN9qTWU4nXDU6EQ6jopvyhXw0dbQ3n+3g6x5hmc1ECFAqA+xmFd1i5LljdZCi79cUXHduQWwvW8RJHMf0qJw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/credential-provider-env": "3.957.0", "@aws-sdk/credential-provider-env": "3.964.0",
"@aws-sdk/credential-provider-http": "3.957.0", "@aws-sdk/credential-provider-http": "3.964.0",
"@aws-sdk/credential-provider-login": "3.962.0", "@aws-sdk/credential-provider-login": "3.964.0",
"@aws-sdk/credential-provider-process": "3.957.0", "@aws-sdk/credential-provider-process": "3.964.0",
"@aws-sdk/credential-provider-sso": "3.958.0", "@aws-sdk/credential-provider-sso": "3.964.0",
"@aws-sdk/credential-provider-web-identity": "3.958.0", "@aws-sdk/credential-provider-web-identity": "3.964.0",
"@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/credential-provider-imds": "^4.2.7", "@smithy/credential-provider-imds": "^4.2.7",
"@smithy/property-provider": "^4.2.7", "@smithy/property-provider": "^4.2.7",
@@ -473,13 +474,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-login": { "node_modules/@aws-sdk/credential-provider-login": {
"version": "3.962.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.962.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.964.0.tgz",
"integrity": "sha512-kHYH6Av2UifG3mPkpPUNRh/PuX6adaAcpmsclJdHdxlixMCRdh8GNeEihq480DC0GmfqdpoSf1w2CLmLLPIS6w==", "integrity": "sha512-c64dmTizMkJXDRzN3NYPTmUpKxegr5lmLOYPeQ60Zcbft6HFwPme8Gwy8pNxO4gG1fw6Ja2Vu6fZuSTn8aDFOQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7", "@smithy/property-provider": "^4.2.7",
"@smithy/protocol-http": "^5.3.7", "@smithy/protocol-http": "^5.3.7",
@@ -492,17 +493,17 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-node": { "node_modules/@aws-sdk/credential-provider-node": {
"version": "3.962.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.962.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.964.0.tgz",
"integrity": "sha512-CS78NsWRxLa+nWqeWBEYMZTLacMFIXs1C5WJuM9kD05LLiWL32ksljoPsvNN24Bc7rCSQIIMx/U3KGvkDVZMVg==", "integrity": "sha512-FHxDXPOj888/qc/X8s0x4aUBdp4Y3k9VePRehUJBWRhhTsAyuIJis5V0iQeY1qvtqHXYa2qd1EZHGJ3bTjHxSw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/credential-provider-env": "3.957.0", "@aws-sdk/credential-provider-env": "3.964.0",
"@aws-sdk/credential-provider-http": "3.957.0", "@aws-sdk/credential-provider-http": "3.964.0",
"@aws-sdk/credential-provider-ini": "3.962.0", "@aws-sdk/credential-provider-ini": "3.964.0",
"@aws-sdk/credential-provider-process": "3.957.0", "@aws-sdk/credential-provider-process": "3.964.0",
"@aws-sdk/credential-provider-sso": "3.958.0", "@aws-sdk/credential-provider-sso": "3.964.0",
"@aws-sdk/credential-provider-web-identity": "3.958.0", "@aws-sdk/credential-provider-web-identity": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/credential-provider-imds": "^4.2.7", "@smithy/credential-provider-imds": "^4.2.7",
"@smithy/property-provider": "^4.2.7", "@smithy/property-provider": "^4.2.7",
@@ -515,12 +516,12 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-process": { "node_modules/@aws-sdk/credential-provider-process": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.964.0.tgz",
"integrity": "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==", "integrity": "sha512-HaTLKqj3jeZY88E/iBjsNJsXgmRTTT7TghqeRiF8FKb/7UY1xEvasBO0c1xqfOye8dsyt35nTfTTyIsd/CBfww==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7", "@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/shared-ini-file-loader": "^4.4.2",
@@ -532,14 +533,14 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-sso": { "node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.958.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.958.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.964.0.tgz",
"integrity": "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==", "integrity": "sha512-oR78TjSpjVf1IpPWQnGHEGqlnQs+K4f5nCxLK2P6JDPprXay6oknsoSiU4x2urav6VCyMPMC9KTCGjBoFKUIxQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-sso": "3.958.0", "@aws-sdk/client-sso": "3.964.0",
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/token-providers": "3.958.0", "@aws-sdk/token-providers": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7", "@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/shared-ini-file-loader": "^4.4.2",
@@ -551,13 +552,13 @@
} }
}, },
"node_modules/@aws-sdk/credential-provider-web-identity": { "node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.958.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.958.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.964.0.tgz",
"integrity": "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==", "integrity": "sha512-07JQDmbjZjOt3nL/j1wTcvQqjmPkynQYftUV/ooZ+qTbmJXFbCBdal1VCElyeiu0AgBq9dfhw0rBBcbND1ZMlA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7", "@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/shared-ini-file-loader": "^4.4.2",
@@ -569,9 +570,9 @@
} }
}, },
"node_modules/@aws-sdk/lib-storage": { "node_modules/@aws-sdk/lib-storage": {
"version": "3.962.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.962.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.964.0.tgz",
"integrity": "sha512-Ai5gWRQkzsUMQ6NPoZZoiLXoQ6/yPRcR4oracIVjyWcu48TfBpsRgbqY/5zNOM55ag1wPX9TtJJGOhK3TNk45g==", "integrity": "sha512-ro6B04Q5TjPgIKdSWGJ+tj2ordVF1IfZJERwGpYkrwhboNEoXBXuzpfnh2LYBPvMmFJQ+8UXSFw1jkLLgxM+ig==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@smithy/abort-controller": "^4.2.7", "@smithy/abort-controller": "^4.2.7",
@@ -586,7 +587,7 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"@aws-sdk/client-s3": "^3.962.0" "@aws-sdk/client-s3": "^3.964.0"
} }
}, },
"node_modules/@aws-sdk/middleware-bucket-endpoint": { "node_modules/@aws-sdk/middleware-bucket-endpoint": {
@@ -623,15 +624,15 @@
} }
}, },
"node_modules/@aws-sdk/middleware-flexible-checksums": { "node_modules/@aws-sdk/middleware-flexible-checksums": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.964.0.tgz",
"integrity": "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA==", "integrity": "sha512-IA2kSKkwC/HHFF75nTR7s/nWt5CboB6vMgpLpvx40Cc01cMp+06Jr7U2/+DPPc8fkCagTytchY4gX9Hzn5ej8g==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32": "5.2.0",
"@aws-crypto/crc32c": "5.2.0", "@aws-crypto/crc32c": "5.2.0",
"@aws-crypto/util": "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/crc64-nvme": "3.957.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/is-array-buffer": "^4.2.0", "@smithy/is-array-buffer": "^4.2.0",
@@ -707,12 +708,12 @@
} }
}, },
"node_modules/@aws-sdk/middleware-sdk-s3": { "node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.964.0.tgz",
"integrity": "sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg==", "integrity": "sha512-SeFcLo3tUdI3amzoIiArd9O0i7vAB0n5fgbQHBu137s3SbSLO5tPspE25rrUITwlc5HTbHMK6UzBq+3hITmImA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@aws-sdk/util-arn-parser": "3.957.0", "@aws-sdk/util-arn-parser": "3.957.0",
"@smithy/core": "^3.20.0", "@smithy/core": "^3.20.0",
@@ -746,12 +747,12 @@
} }
}, },
"node_modules/@aws-sdk/middleware-user-agent": { "node_modules/@aws-sdk/middleware-user-agent": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.964.0.tgz",
"integrity": "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==", "integrity": "sha512-/QyBl8WLNtqw3ucyAggumQXVCi8GRxaDGE1ElyYMmacfiwHl37S9y8JVW/QLL1lIEXGcsrhMUKV3pyFJFALA7w==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0",
"@smithy/core": "^3.20.0", "@smithy/core": "^3.20.0",
@@ -764,23 +765,23 @@
} }
}, },
"node_modules/@aws-sdk/nested-clients": { "node_modules/@aws-sdk/nested-clients": {
"version": "3.958.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.958.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.964.0.tgz",
"integrity": "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==", "integrity": "sha512-ql+ftRwjyZkZeG3qbrRJFVmNR0id83WEUqhFVjvrQMWspNApBhz0Ar4YVSn7Uv0QaKkaR7ALPtmdMzFr3/E4bQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "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-host-header": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "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/region-config-resolver": "3.957.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "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/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0", "@smithy/core": "^3.20.0",
"@smithy/fetch-http-handler": "^5.3.8", "@smithy/fetch-http-handler": "^5.3.8",
@@ -829,12 +830,12 @@
} }
}, },
"node_modules/@aws-sdk/s3-request-presigner": { "node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.962.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.962.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.964.0.tgz",
"integrity": "sha512-tyxsGfLY4NSohLrJsFGXbE3j8jguWK+hdGaUQSD1gJPvmC0B82qOyJ7WBIJLWgTabU3fiF/I9EGXjzR2rKr8jQ==", "integrity": "sha512-gKKdIZGYV8Ohm3X8j3y6Xr2ua1oD/Wsa3N7hYro3HqcnuGvl1h+mdw0IqUU+5yEzcoM5ItLJnH+6Q8Xz+Wv9gw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "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/types": "3.957.0",
"@aws-sdk/util-format-url": "3.957.0", "@aws-sdk/util-format-url": "3.957.0",
"@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-endpoint": "^4.4.1",
@@ -848,12 +849,12 @@
} }
}, },
"node_modules/@aws-sdk/signature-v4-multi-region": { "node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.964.0.tgz",
"integrity": "sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==", "integrity": "sha512-ASQmO9EB2ukSTGpO7B2ZceSbNVivCLqWh89o/JJtcIdGpOu8p9XHpeK3hiUz2OQo2Igw03/n8s+DNvP+N9krpw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/middleware-sdk-s3": "3.957.0", "@aws-sdk/middleware-sdk-s3": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/protocol-http": "^5.3.7", "@smithy/protocol-http": "^5.3.7",
"@smithy/signature-v4": "^5.3.7", "@smithy/signature-v4": "^5.3.7",
@@ -865,13 +866,13 @@
} }
}, },
"node_modules/@aws-sdk/token-providers": { "node_modules/@aws-sdk/token-providers": {
"version": "3.958.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.958.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.964.0.tgz",
"integrity": "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==", "integrity": "sha512-UqouLQbYepZnMFJGB/DVpA5GhF9uT98vNWSMz9PVbhgEPUKa73FECRT6YFZvZOh8kA+0JiENrnmS6d93I70ykQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.957.0", "@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7", "@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/shared-ini-file-loader": "^4.4.2",
@@ -963,12 +964,12 @@
} }
}, },
"node_modules/@aws-sdk/util-user-agent-node": { "node_modules/@aws-sdk/util-user-agent-node": {
"version": "3.957.0", "version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.957.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.964.0.tgz",
"integrity": "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==", "integrity": "sha512-jgob8Z/bZIh1dwEgLqE12q+aCf0ieLy7anT8bWpqMijMJqsnrPBToa7smSykfom9YHrdOgrQhXswMpE75dzLRw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/types": "3.957.0", "@aws-sdk/types": "3.957.0",
"@smithy/node-config-provider": "^4.3.7", "@smithy/node-config-provider": "^4.3.7",
"@smithy/types": "^4.11.0", "@smithy/types": "^4.11.0",
@@ -5399,30 +5400,6 @@
"node": ">= 0.8" "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": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -6883,12 +6860,12 @@
} }
}, },
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 10"
} }
}, },
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
@@ -9661,6 +9638,15 @@
"node": ">= 0.10" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+1
View File
@@ -34,6 +34,7 @@
"i18next": "25.3.2", "i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2", "i18next-http-backend": "^3.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1", "joi": "^17.9.1",
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
+2
View File
@@ -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/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename')); 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/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS')); app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages')); app.use('/api/images', require('./src/routes/protectedImages'));
+32 -5
View File
@@ -57,10 +57,35 @@ async function adminAuth(req, res, next) {
}); });
} }
// Check if admin still exists and is active // Check if admin still exists and is active, including role info
const admin = await db('admin_users') // Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
.where({ id: decoded.id, is_active: formatBoolean(true) }) 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(); .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) { if (!admin) {
return res.status(401).json({ error: 'Invalid token' }); return res.status(401).json({ error: 'Invalid token' });
@@ -78,11 +103,13 @@ async function adminAuth(req, res, next) {
} }
} }
// Add user info to request // Add user info to request (enhanced with role)
req.admin = { req.admin = {
id: admin.id, id: admin.id,
username: admin.username, 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 req.token = token; // Store token for potential revocation
+242
View File
@@ -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<boolean>}
*/
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<boolean>}
*/
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<boolean>}
*/
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
};
+75
View File
@@ -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;
+6 -5
View File
@@ -4,12 +4,13 @@ const fs = require('fs').promises;
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver'); const archiver = require('archiver');
const AdmZip = require('adm-zip'); const AdmZip = require('adm-zip');
const router = express.Router(); const router = express.Router();
// Get all archived events // Get all archived events
router.get('/', adminAuth, async (req, res) => { router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
try { try {
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20; const limit = parseInt(req.query.limit) || 20;
@@ -81,7 +82,7 @@ router.get('/', adminAuth, async (req, res) => {
}); });
// Get single archive details // Get single archive details
router.get('/:id', adminAuth, async (req, res) => { router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
try { try {
const archive = await db('events') const archive = await db('events')
.where('id', req.params.id) .where('id', req.params.id)
@@ -137,7 +138,7 @@ router.get('/:id', adminAuth, async (req, res) => {
}); });
// Restore archive // Restore archive
router.post('/:id/restore', adminAuth, async (req, res) => { router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
try { try {
const archive = await db('events') const archive = await db('events')
.where('id', req.params.id) .where('id', req.params.id)
@@ -300,7 +301,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
}); });
// Download archive // Download archive
router.get('/:id/download', adminAuth, async (req, res) => { router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
try { try {
const archive = await db('events') const archive = await db('events')
.where('id', req.params.id) .where('id', req.params.id)
@@ -349,7 +350,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
}); });
// Delete archive permanently // Delete archive permanently
router.delete('/:id', adminAuth, async (req, res) => { router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
try { try {
const archive = await db('events') const archive = await db('events')
.where('id', req.params.id) .where('id', req.params.id)
+22 -21
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService'); const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const fs = require('fs').promises; const fs = require('fs').promises;
@@ -12,7 +13,7 @@ const S3StorageAdapter = require('../services/storage/s3Storage');
const router = express.Router(); const router = express.Router();
// Get backup configuration // Get backup configuration
router.get('/config', adminAuth, async (req, res) => { router.get('/config', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const settings = await db('app_settings') const settings = await db('app_settings')
.where('setting_type', 'backup') .where('setting_type', 'backup')
@@ -35,7 +36,7 @@ router.get('/config', adminAuth, async (req, res) => {
}); });
// Update backup configuration // Update backup configuration
router.put('/config', adminAuth, async (req, res) => { router.put('/config', adminAuth, requirePermission('backup.create'), async (req, res) => {
try { try {
const updates = req.body; const updates = req.body;
@@ -97,7 +98,7 @@ router.put('/config', adminAuth, async (req, res) => {
}); });
// Get backup status and history // Get backup status and history
router.get('/status', adminAuth, async (req, res) => { router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
const status = await getBackupStatus(limit); const status = await getBackupStatus(limit);
@@ -110,7 +111,7 @@ router.get('/status', adminAuth, async (req, res) => {
}); });
// Trigger manual backup // Trigger manual backup
router.post('/run', adminAuth, async (req, res) => { router.post('/run', adminAuth, requirePermission('backup.create'), async (req, res) => {
try { try {
// Check if backup is already running // Check if backup is already running
const status = await getBackupStatus(); const status = await getBackupStatus();
@@ -131,7 +132,7 @@ router.post('/run', adminAuth, async (req, res) => {
}); });
// Get backup run details // Get backup run details
router.get('/runs/:id', adminAuth, async (req, res) => { router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -160,7 +161,7 @@ router.get('/runs/:id', adminAuth, async (req, res) => {
}); });
// Get file states (for debugging/monitoring) // Get file states (for debugging/monitoring)
router.get('/files', adminAuth, async (req, res) => { router.get('/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const { page = 1, limit = 50, search = '' } = req.query; const { page = 1, limit = 50, search = '' } = req.query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
@@ -195,7 +196,7 @@ router.get('/files', adminAuth, async (req, res) => {
}); });
// Clean up old backup runs // Clean up old backup runs
router.delete('/cleanup', adminAuth, async (req, res) => { router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
try { try {
const { days = 30 } = req.body; const { days = 30 } = req.body;
@@ -209,7 +210,7 @@ router.delete('/cleanup', adminAuth, async (req, res) => {
}); });
// Test backup destination connectivity // Test backup destination connectivity
router.post('/test-connection', adminAuth, async (req, res) => { router.post('/test-connection', adminAuth, requirePermission('backup.create'), async (req, res) => {
try { try {
const { destination_type, ...config } = req.body; 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 // 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 { try {
const { backupRunId } = req.params; const { backupRunId } = req.params;
const result = await getBackupManifest(backupRunId); const result = await getBackupManifest(backupRunId);
@@ -351,7 +352,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
}); });
// Validate a backup manifest // Validate a backup manifest
router.post('/manifest/validate', adminAuth, async (req, res) => { router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const { manifestPath } = req.body; const { manifestPath } = req.body;
@@ -373,7 +374,7 @@ router.post('/manifest/validate', adminAuth, async (req, res) => {
}); });
// Download backup manifest // 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 { try {
const { backupRunId } = req.params; const { backupRunId } = req.params;
const { format = 'json' } = req.query; const { format = 'json' } = req.query;
@@ -404,7 +405,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
}); });
// Get manifest for specific backup // 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 { try {
const { backupId } = req.params; const { backupId } = req.params;
const result = await getBackupManifest(backupId); const result = await getBackupManifest(backupId);
@@ -421,7 +422,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
}); });
// Download manifest file // 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 { try {
const { backupId } = req.params; const { backupId } = req.params;
const { format = 'json' } = req.query; const { format = 'json' } = req.query;
@@ -452,7 +453,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
}); });
// Validate a manifest // Validate a manifest
router.post('/manifests/validate', adminAuth, async (req, res) => { router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const { manifestPath, manifestData } = req.body; const { manifestPath, manifestData } = req.body;
@@ -481,7 +482,7 @@ router.post('/manifests/validate', adminAuth, async (req, res) => {
}); });
// List S3 buckets // List S3 buckets
router.get('/s3/buckets', adminAuth, async (req, res) => { router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const config = await getBackupConfig(); const config = await getBackupConfig();
@@ -513,7 +514,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
}); });
// List files in S3 backup location // 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 { try {
const { prefix = '', maxKeys = 100, continuationToken } = req.query; const { prefix = '', maxKeys = 100, continuationToken } = req.query;
const config = await getBackupConfig(); const config = await getBackupConfig();
@@ -550,7 +551,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
}); });
// Clean up old S3 backups // 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 { try {
const { retentionDays = 30, dryRun = false } = req.body; const { retentionDays = 30, dryRun = false } = req.body;
const config = await getBackupConfig(); const config = await getBackupConfig();
@@ -612,7 +613,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
}); });
// Test S3 upload functionality // 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 { try {
const config = await getBackupConfig(); const config = await getBackupConfig();
@@ -664,7 +665,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
}); });
// Download entire backup // Download entire backup
router.get('/download/:backupId', adminAuth, async (req, res) => { router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const { backupId } = req.params; const { backupId } = req.params;
@@ -752,7 +753,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
}); });
// Get current file checksums // Get current file checksums
router.get('/checksums', adminAuth, async (req, res) => { router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const { path: targetPath = '', recursive = true } = req.query; const { path: targetPath = '', recursive = true } = req.query;
const checksums = {}; const checksums = {};
@@ -821,7 +822,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
}); });
// Estimate backup size before running // Estimate backup size before running
router.post('/estimate', adminAuth, async (req, res) => { router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try { try {
const { includeArchived = true } = req.body; const { includeArchived = true } = req.body;
+4 -3
View File
@@ -2,10 +2,11 @@ const express = require('express');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router(); const router = express.Router();
// Get all CMS pages // Get all CMS pages
router.get('/pages', adminAuth, async (req, res) => { router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
try { try {
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc'); const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
res.json(pages); res.json(pages);
@@ -16,7 +17,7 @@ router.get('/pages', adminAuth, async (req, res) => {
}); });
// Get a single CMS page // 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 { try {
const { slug } = req.params; const { slug } = req.params;
const page = await db('cms_pages').where('slug', slug).first(); 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 // Update a CMS page
router.put('/pages/:slug', adminAuth, [ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('title_en').optional().isString(), body('title_en').optional().isString(),
body('title_de').optional().isString(), body('title_de').optional().isString(),
body('content_en').optional().isString(), body('content_en').optional().isString(),
+6 -5
View File
@@ -3,10 +3,11 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router(); const router = express.Router();
// Get all global categories // Get all global categories
router.get('/global', adminAuth, async (req, res) => { router.get('/global', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const categories = await db('photo_categories') const categories = await db('photo_categories')
.where('is_global', formatBoolean(true)) .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) // 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 { try {
const { eventId } = req.params; const { eventId } = req.params;
@@ -40,7 +41,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
}); });
// Create a new category // Create a new category
router.post('/', adminAuth, [ router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required'), body('name').notEmpty().withMessage('Category name is required'),
body('slug').optional(), body('slug').optional(),
body('is_global').optional().isBoolean(), body('is_global').optional().isBoolean(),
@@ -104,7 +105,7 @@ router.post('/', adminAuth, [
}); });
// Update a category // Update a category
router.put('/:id', adminAuth, [ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required') body('name').notEmpty().withMessage('Category name is required')
], async (req, res) => { ], async (req, res) => {
try { try {
@@ -149,7 +150,7 @@ router.put('/:id', adminAuth, [
}); });
// Delete a category // Delete a category
router.delete('/:id', adminAuth, async (req, res) => { router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
+6 -5
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const { body, param, validationResult } = require('express-validator'); const { body, param, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer'); const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates'); 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 /admin/css-templates
* Get all CSS templates * Get all CSS templates
*/ */
router.get('/', adminAuth, async (req, res) => { router.get('/', adminAuth, requirePermission('branding.view'), async (req, res) => {
try { try {
const templates = await withRetry(() => const templates = await withRetry(() =>
db('css_templates').orderBy('slot_number') db('css_templates').orderBy('slot_number')
@@ -31,7 +32,7 @@ router.get('/', adminAuth, async (req, res) => {
* GET /admin/css-templates/enabled * GET /admin/css-templates/enabled
* Get only enabled templates (for event form dropdown) * 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 { try {
const templates = await withRetry(() => const templates = await withRetry(() =>
db('css_templates') db('css_templates')
@@ -50,7 +51,7 @@ router.get('/enabled', adminAuth, async (req, res) => {
* GET /admin/css-templates/:slotNumber * GET /admin/css-templates/:slotNumber
* Get a specific template by slot number * 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 }) param('slotNumber').isInt({ min: 1, max: 3 })
], async (req, res) => { ], async (req, res) => {
try { try {
@@ -81,7 +82,7 @@ router.get('/:slotNumber', adminAuth, [
* PUT /admin/css-templates/:slotNumber * PUT /admin/css-templates/:slotNumber
* Update a template * Update a template
*/ */
router.put('/:slotNumber', adminAuth, [ router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
param('slotNumber').isInt({ min: 1, max: 3 }), param('slotNumber').isInt({ min: 1, max: 3 }),
body('name').optional().isString().isLength({ max: 50 }), body('name').optional().isString().isLength({ max: 50 }),
body('css_content').optional().isString(), body('css_content').optional().isString(),
@@ -158,7 +159,7 @@ router.put('/:slotNumber', adminAuth, [
* POST /admin/css-templates/:slotNumber/reset * POST /admin/css-templates/:slotNumber/reset
* Reset template to default (only for slot 1) * 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') param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
], async (req, res) => { ], async (req, res) => {
try { try {
+5 -4
View File
@@ -1,12 +1,13 @@
const express = require('express'); const express = require('express');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity'); const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const router = express.Router(); const router = express.Router();
// Get dashboard statistics // Get dashboard statistics
router.get('/stats', adminAuth, async (req, res) => { router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try { try {
// Get active events count // Get active events count
const activeEvents = await db('events') const activeEvents = await db('events')
@@ -106,7 +107,7 @@ router.get('/stats', adminAuth, async (req, res) => {
}); });
// Get recent activity // Get recent activity
router.get('/activity', adminAuth, async (req, res) => { router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try { try {
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
@@ -144,7 +145,7 @@ router.get('/activity', adminAuth, async (req, res) => {
}); });
// Get system health status // Get system health status
router.get('/health', adminAuth, async (req, res) => { router.get('/health', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const os = require('os'); const os = require('os');
@@ -216,7 +217,7 @@ router.get('/health', adminAuth, async (req, res) => {
}); });
// Get analytics data for charts // Get analytics data for charts
router.get('/analytics', adminAuth, async (req, res) => { router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try { try {
const days = sanitizeDays(req.query.days || 7); const days = sanitizeDays(req.query.days || 7);
+9 -8
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { databaseBackupService } = require('../services/databaseBackup'); const { databaseBackupService } = require('../services/databaseBackup');
const { db } = require('../database/db'); const { db } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -11,7 +12,7 @@ router.use(adminAuth);
/** /**
* Get database backup status and configuration * Get database backup status and configuration
*/ */
router.get('/status', async (req, res) => { router.get('/status', requirePermission('backup.view'), async (req, res) => {
try { try {
// Get configuration // Get configuration
const config = await databaseBackupService.getBackupConfig(); const config = await databaseBackupService.getBackupConfig();
@@ -45,7 +46,7 @@ router.get('/status', async (req, res) => {
/** /**
* Update database backup configuration * Update database backup configuration
*/ */
router.put('/config', async (req, res) => { router.put('/config', requirePermission('backup.create'), async (req, res) => {
try { try {
const allowedSettings = [ const allowedSettings = [
'database_backup_enabled', 'database_backup_enabled',
@@ -108,7 +109,7 @@ router.put('/config', async (req, res) => {
/** /**
* Trigger manual database backup * Trigger manual database backup
*/ */
router.post('/backup', async (req, res) => { router.post('/backup', requirePermission('backup.create'), async (req, res) => {
try { try {
if (databaseBackupService.isRunning) { if (databaseBackupService.isRunning) {
return res.status(409).json({ error: 'Backup already in progress' }); return res.status(409).json({ error: 'Backup already in progress' });
@@ -134,7 +135,7 @@ router.post('/backup', async (req, res) => {
/** /**
* Get current backup progress * Get current backup progress
*/ */
router.get('/progress', async (req, res) => { router.get('/progress', requirePermission('backup.view'), async (req, res) => {
try { try {
const progress = databaseBackupService.getProgress(); const progress = databaseBackupService.getProgress();
@@ -151,7 +152,7 @@ router.get('/progress', async (req, res) => {
/** /**
* Get backup history with pagination * Get backup history with pagination
*/ */
router.get('/history', async (req, res) => { router.get('/history', requirePermission('backup.view'), async (req, res) => {
try { try {
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20; const limit = parseInt(req.query.limit) || 20;
@@ -183,7 +184,7 @@ router.get('/history', async (req, res) => {
/** /**
* Delete old backup files * Delete old backup files
*/ */
router.delete('/cleanup', async (req, res) => { router.delete('/cleanup', requirePermission('backup.delete'), async (req, res) => {
try { try {
const { retentionDays = 30 } = req.body; const { retentionDays = 30 } = req.body;
@@ -202,7 +203,7 @@ router.delete('/cleanup', async (req, res) => {
/** /**
* Test database backup configuration * Test database backup configuration
*/ */
router.post('/test', async (req, res) => { router.post('/test', requirePermission('backup.create'), async (req, res) => {
try { try {
const config = await databaseBackupService.getBackupConfig(); const config = await databaseBackupService.getBackupConfig();
@@ -255,7 +256,7 @@ router.post('/test', async (req, res) => {
/** /**
* Get table checksums * Get table checksums
*/ */
router.get('/checksums', async (req, res) => { router.get('/checksums', requirePermission('backup.view'), async (req, res) => {
try { try {
const checksums = await databaseBackupService.getTableChecksums(); const checksums = await databaseBackupService.getTableChecksums();
+8 -5
View File
@@ -3,10 +3,11 @@ const nodemailer = require('nodemailer');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router(); const router = express.Router();
// Get email configuration // Get email configuration
router.get('/config', adminAuth, async (req, res) => { router.get('/config', adminAuth, requirePermission('email.view'), async (req, res) => {
try { try {
const config = await db('email_configs').first(); const config = await db('email_configs').first();
@@ -37,6 +38,7 @@ router.get('/config', adminAuth, async (req, res) => {
// Update email configuration // Update email configuration
router.post('/config', [ router.post('/config', [
adminAuth, adminAuth,
requirePermission('email.edit'),
body('smtp_host').notEmpty().withMessage('SMTP host is required'), body('smtp_host').notEmpty().withMessage('SMTP host is required'),
body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'), body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'),
body('from_email').isEmail().withMessage('Invalid from email address') body('from_email').isEmail().withMessage('Invalid from email address')
@@ -100,7 +102,7 @@ router.post('/config', [
}); });
// Test email configuration // Test email configuration
router.post('/test', adminAuth, async (req, res) => { router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
try { try {
const { test_email } = req.body; const { test_email } = req.body;
@@ -236,7 +238,7 @@ router.post('/test', adminAuth, async (req, res) => {
}); });
// Get email templates // Get email templates
router.get('/templates', adminAuth, async (req, res) => { router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
try { try {
const templates = await db('email_templates') const templates = await db('email_templates')
.select('*') .select('*')
@@ -290,7 +292,7 @@ router.get('/templates', adminAuth, async (req, res) => {
}); });
// Get single template // Get single template
router.get('/templates/:key', adminAuth, async (req, res) => { router.get('/templates/:key', adminAuth, requirePermission('email.view'), async (req, res) => {
try { try {
const template = await db('email_templates') const template = await db('email_templates')
.where('template_key', req.params.key) .where('template_key', req.params.key)
@@ -346,6 +348,7 @@ router.get('/templates/:key', adminAuth, async (req, res) => {
// Update email template // Update email template
router.put('/templates/:key', [ router.put('/templates/:key', [
adminAuth, adminAuth,
requirePermission('email.edit'),
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'), body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
body('subject_de').optional().notEmpty().withMessage('German 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'), body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
@@ -424,7 +427,7 @@ router.put('/templates/:key', [
}); });
// Preview email template // 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 { try {
const template = await db('email_templates') const template = await db('email_templates')
.where('template_key', req.params.key) .where('template_key', req.params.key)
+3 -2
View File
@@ -6,6 +6,7 @@
const express = require('express'); const express = require('express');
const { body, validationResult } = require('express-validator'); const { body, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const eventRenameService = require('../services/eventRenameService'); const eventRenameService = require('../services/eventRenameService');
const router = express.Router(); const router = express.Router();
@@ -13,7 +14,7 @@ const router = express.Router();
* POST /api/admin/events/:eventId/rename * POST /api/admin/events/:eventId/rename
* Rename an event * Rename an event
*/ */
router.post('/:eventId/rename', adminAuth, [ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
body('newEventName') body('newEventName')
.trim() .trim()
.isLength({ min: 3, max: 100 }) .isLength({ min: 3, max: 100 })
@@ -58,7 +59,7 @@ router.post('/:eventId/rename', adminAuth, [
* POST /api/admin/events/:eventId/validate-rename * POST /api/admin/events/:eventId/validate-rename
* Validate a potential rename without executing it * Validate a potential rename without executing it
*/ */
router.post('/:eventId/validate-rename', adminAuth, [ router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
body('newEventName') body('newEventName')
.trim() .trim()
.isLength({ min: 3, max: 100 }) .isLength({ min: 3, max: 100 })
+2 -1
View File
@@ -3,9 +3,10 @@
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService'); const { buildShareLinkVariants } = require('../services/shareLinkService');
const { requirePermission } = require('../middleware/permissions');
// Enhanced event creation with password validation // 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_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(), body('event_name').notEmpty().trim(),
body('event_date').isDate(), body('event_date').isDate(),
+51 -20
View File
@@ -3,6 +3,7 @@ const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router(); const router = express.Router();
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
@@ -102,7 +103,7 @@ const hasCustomerContactColumns = async () => {
}; };
// Create new event // Create new event
router.post('/', adminAuth, [ router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(), body('event_name').notEmpty().trim(),
body('event_date').isDate(), body('event_date').isDate(),
@@ -296,6 +297,7 @@ router.post('/', adminAuth, [
share_token: shareToken, share_token: shareToken,
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads, allow_user_uploads,
upload_category_id, upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
@@ -375,7 +377,7 @@ router.post('/', adminAuth, [
}); });
// Get all events with pagination and filters // Get all events with pagination and filters
router.get('/', adminAuth, async (req, res) => { router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => {
try { try {
const page = parseInt(req.query.page) || 1; const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20; const limit = parseInt(req.query.limit) || 20;
@@ -388,6 +390,11 @@ router.get('/', adminAuth, async (req, res) => {
// Build query // Build query
let query = db('events'); 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 // Apply search filter
if (search) { if (search) {
const escapedSearch = escapeLikePattern(search); const escapedSearch = escapeLikePattern(search);
@@ -465,13 +472,18 @@ router.get('/', adminAuth, async (req, res) => {
}); });
// Get single event details // Get single event details
router.get('/:id', adminAuth, async (req, res) => { router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const event = await db('events') let query = db('events').where('id', id);
.where('id', id)
.first(); // 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) { if (!event) {
return res.status(404).json({ error: 'Event not found' }); return res.status(404).json({ error: 'Event not found' });
@@ -525,7 +537,7 @@ router.get('/:id', adminAuth, async (req, res) => {
}); });
// Update event // Update event
router.put('/:id', adminAuth, [ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('event_name').optional().trim().notEmpty(), body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(), body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(), body('is_active').optional().isBoolean(),
@@ -569,7 +581,8 @@ router.put('/:id', adminAuth, [
throw new Error('Password must be at least 6 characters long'); throw new Error('Password must be at least 6 characters long');
} }
return true; return true;
}) }),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -659,7 +672,12 @@ router.put('/:id', adminAuth, [
}); });
// Check if event exists // 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) { if (!event) {
return res.status(404).json({ error: 'Event not found' }); return res.status(404).json({ error: 'Event not found' });
} }
@@ -696,7 +714,7 @@ router.put('/:id', adminAuth, [
}); });
// Delete event // Delete event
router.delete('/:id', adminAuth, async (req, res) => { router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -777,11 +795,16 @@ router.delete('/:id', adminAuth, async (req, res) => {
}); });
// Toggle event status // 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 { try {
const { id } = req.params; 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) { if (!event) {
return res.status(404).json({ error: 'Event not found' }); 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 // 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 { try {
const { id } = req.params; const { id } = req.params;
const { sendEmail = true } = req.body; 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) { if (!event) {
return res.status(404).json({ error: 'Event not found' }); return res.status(404).json({ error: 'Event not found' });
} }
@@ -874,14 +902,17 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
}); });
// Resend creation email // 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 { try {
const { id } = req.params; const { id } = req.params;
// Get event details // Get event details
const event = await db('events') let eventQuery = db('events').where('id', id);
.where('id', id) // Editor role can only edit their own events
.first(); if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Event not found' }); return res.status(404).json({ error: 'Event not found' });
@@ -954,7 +985,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
}); });
// Archive event // Archive event
router.post('/:id/archive', adminAuth, async (req, res) => { router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -985,7 +1016,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
}); });
// Bulk archive events // 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').isArray().withMessage('eventIds must be an array'),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer') body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
], async (req, res) => { ], async (req, res) => {
+3 -2
View File
@@ -2,6 +2,7 @@ const express = require('express');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService'); const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
// GET /api/admin/external-media/list?path=relative/dir // 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 { try {
const relPath = (req.query.path || '').replace(/^\/+/, ''); const relPath = (req.query.path || '').replace(/^\/+/, '');
const result = await list(relPath); const result = await list(relPath);
@@ -45,7 +46,7 @@ async function walkDir(dir, baseDir) {
// POST /api/admin/events/:id/import-external // POST /api/admin/events/:id/import-external
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } } // 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 { try {
const eventId = parseInt(req.params.id); const eventId = parseInt(req.params.id);
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {}; const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
+13
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const feedbackService = require('../services/feedbackService'); const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration'); const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
@@ -15,6 +16,7 @@ const {
// Get event feedback settings // Get event feedback settings
router.get('/events/:eventId/feedback-settings', router.get('/events/:eventId/feedback-settings',
adminAuth, adminAuth,
requirePermission('events.view'),
validateEventId, validateEventId,
checkValidation, checkValidation,
async (req, res) => { async (req, res) => {
@@ -39,6 +41,7 @@ router.get('/events/:eventId/feedback-settings',
// Update event feedback settings // Update event feedback settings
router.put('/events/:eventId/feedback-settings', router.put('/events/:eventId/feedback-settings',
adminAuth, adminAuth,
requirePermission('events.edit'),
validateEventId, validateEventId,
validateFeedbackSettings, validateFeedbackSettings,
checkValidation, checkValidation,
@@ -75,6 +78,7 @@ router.put('/events/:eventId/feedback-settings',
// Get feedback for an event (with filters) // Get feedback for an event (with filters)
router.get('/events/:eventId/feedback', router.get('/events/:eventId/feedback',
adminAuth, adminAuth,
requirePermission('events.view'),
validateEventId, validateEventId,
checkValidation, checkValidation,
async (req, res) => { async (req, res) => {
@@ -159,6 +163,7 @@ router.get('/events/:eventId/feedback',
// Moderate feedback (approve/hide/reject) // Moderate feedback (approve/hide/reject)
router.put('/feedback/:feedbackId/:action', router.put('/feedback/:feedbackId/:action',
adminAuth, adminAuth,
requirePermission('events.edit'),
async (req, res) => { async (req, res) => {
try { try {
const { feedbackId, action } = req.params; const { feedbackId, action } = req.params;
@@ -180,6 +185,7 @@ router.put('/feedback/:feedbackId/:action',
// Delete feedback // Delete feedback
router.delete('/feedback/:feedbackId', router.delete('/feedback/:feedbackId',
adminAuth, adminAuth,
requirePermission('events.delete'),
async (req, res) => { async (req, res) => {
try { try {
const { feedbackId } = req.params; const { feedbackId } = req.params;
@@ -197,6 +203,7 @@ router.delete('/feedback/:feedbackId',
// Get feedback analytics for an event // Get feedback analytics for an event
router.get('/events/:eventId/feedback-analytics', router.get('/events/:eventId/feedback-analytics',
adminAuth, adminAuth,
requirePermission('events.view'),
validateEventId, validateEventId,
checkValidation, checkValidation,
async (req, res) => { async (req, res) => {
@@ -296,6 +303,7 @@ router.get('/events/:eventId/feedback-analytics',
// Export feedback data // Export feedback data
router.get('/events/:eventId/feedback/export', router.get('/events/:eventId/feedback/export',
adminAuth, adminAuth,
requirePermission('events.view'),
validateEventId, validateEventId,
checkValidation, checkValidation,
async (req, res) => { async (req, res) => {
@@ -324,6 +332,7 @@ router.get('/events/:eventId/feedback/export',
// Get pending moderation items (across all events) // Get pending moderation items (across all events)
router.get('/feedback/pending-moderation', router.get('/feedback/pending-moderation',
adminAuth, adminAuth,
requirePermission('events.view'),
async (req, res) => { async (req, res) => {
try { try {
const pending = await feedbackService.getPendingModeration(); const pending = await feedbackService.getPendingModeration();
@@ -338,6 +347,7 @@ router.get('/feedback/pending-moderation',
// Word filter management // Word filter management
router.get('/word-filters', router.get('/word-filters',
adminAuth, adminAuth,
requirePermission('settings.view'),
async (req, res) => { async (req, res) => {
try { try {
const filters = await feedbackModeration.getAllWordFilters(); const filters = await feedbackModeration.getAllWordFilters();
@@ -351,6 +361,7 @@ router.get('/word-filters',
router.post('/word-filters', router.post('/word-filters',
adminAuth, adminAuth,
requirePermission('settings.edit'),
validateWordFilter, validateWordFilter,
checkValidation, checkValidation,
async (req, res) => { async (req, res) => {
@@ -378,6 +389,7 @@ router.post('/word-filters',
router.put('/word-filters/:id', router.put('/word-filters/:id',
adminAuth, adminAuth,
requirePermission('settings.edit'),
async (req, res) => { async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -395,6 +407,7 @@ router.put('/word-filters/:id',
router.delete('/word-filters/:id', router.delete('/word-filters/:id',
adminAuth, adminAuth,
requirePermission('settings.edit'),
async (req, res) => { async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
+9 -8
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const secureImageMiddleware = require('../middleware/secureImageMiddleware'); const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
@@ -9,7 +10,7 @@ const router = express.Router();
/** /**
* Get image security settings * Get image security settings
*/ */
router.get('/settings', adminAuth, async (req, res) => { router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const settings = await db('app_settings') const settings = await db('app_settings')
.whereIn('setting_key', [ .whereIn('setting_key', [
@@ -46,7 +47,7 @@ router.get('/settings', adminAuth, async (req, res) => {
/** /**
* Update image security settings * Update image security settings
*/ */
router.put('/settings', adminAuth, async (req, res) => { router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const updates = req.body; const updates = req.body;
@@ -97,7 +98,7 @@ router.put('/settings', adminAuth, async (req, res) => {
/** /**
* Get security monitoring dashboard data * Get security monitoring dashboard data
*/ */
router.get('/dashboard', adminAuth, async (req, res) => { router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const { timeframe = '24h' } = req.query; const { timeframe = '24h' } = req.query;
@@ -202,7 +203,7 @@ router.get('/dashboard', adminAuth, async (req, res) => {
/** /**
* Get detailed security logs * Get detailed security logs
*/ */
router.get('/logs', adminAuth, async (req, res) => { router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const { const {
page = 1, page = 1,
@@ -271,7 +272,7 @@ router.get('/logs', adminAuth, async (req, res) => {
/** /**
* Get image access logs for a specific event * 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 { try {
const { eventId } = req.params; const { eventId } = req.params;
const { page = 1, limit = 50 } = req.query; 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 * Block/unblock suspicious IPs
*/ */
router.post('/block-ip', adminAuth, async (req, res) => { router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const { ip, action = 'block' } = req.body; 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 * 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 { try {
const { olderThan = '30d' } = req.body; const { olderThan = '30d' } = req.body;
@@ -424,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, async (req, res) => {
/** /**
* Export security data for analysis * Export security data for analysis
*/ */
router.get('/export', adminAuth, async (req, res) => { router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const { format = 'json', timeframe = '7d' } = req.query; const { format = 'json', timeframe = '7d' } = req.query;
+5 -4
View File
@@ -1,10 +1,11 @@
const express = require('express'); const express = require('express');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router(); const router = express.Router();
// Get notifications (unread activity logs) // Get notifications (unread activity logs)
router.get('/', adminAuth, async (req, res) => { router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const { limit = 20, includeRead = false } = req.query; const { limit = 20, includeRead = false } = req.query;
@@ -64,7 +65,7 @@ router.get('/', adminAuth, async (req, res) => {
}); });
// Mark notification as read // Mark notification as read
router.put('/:id/read', adminAuth, async (req, res) => { router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
@@ -82,7 +83,7 @@ router.put('/:id/read', adminAuth, async (req, res) => {
}); });
// Mark all notifications as read // 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 { try {
await db('activity_logs') await db('activity_logs')
.whereNull('read_at') .whereNull('read_at')
@@ -98,7 +99,7 @@ router.put('/read-all', adminAuth, async (req, res) => {
}); });
// Delete old notifications (older than 30 days and read) // 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 { try {
// Use database-agnostic date calculation // Use database-agnostic date calculation
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
+5 -4
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const { body, query, validationResult } = require('express-validator'); const { body, query, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder'); const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
const { PhotoExportService } = require('../services/photoExportService'); const { PhotoExportService } = require('../services/photoExportService');
@@ -17,7 +18,7 @@ const exportService = new PhotoExportService();
* GET /admin/photos/:eventId/filtered * GET /admin/photos/:eventId/filtered
* Get filtered photos with pagination * 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('min_rating').optional().isFloat({ min: 0, max: 5 }),
query('max_rating').optional().isFloat({ min: 0, max: 5 }), query('max_rating').optional().isFloat({ min: 0, max: 5 }),
query('has_likes').optional().isBoolean(), query('has_likes').optional().isBoolean(),
@@ -131,7 +132,7 @@ router.get('/:eventId/filtered', adminAuth, [
* GET /admin/photos/:eventId/filter-summary * GET /admin/photos/:eventId/filter-summary
* Get just the summary counts for filter UI * 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 { try {
const eventId = parseInt(req.params.eventId); const eventId = parseInt(req.params.eventId);
@@ -153,7 +154,7 @@ router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
* POST /admin/photos/:eventId/export * POST /admin/photos/:eventId/export
* Export selected or filtered photos * 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().isArray(),
body('photo_ids.*').optional().isInt(), body('photo_ids.*').optional().isInt(),
body('filter').optional().isObject(), body('filter').optional().isObject(),
@@ -213,7 +214,7 @@ router.post('/:eventId/export', adminAuth, [
* GET /admin/photos/export-formats * GET /admin/photos/export-formats
* Get available export format options * Get available export format options
*/ */
router.get('/export-formats', adminAuth, (req, res) => { router.get('/export-formats', adminAuth, requirePermission('photos.view'), (req, res) => {
res.json({ res.json({
success: true, success: true,
data: PhotoExportService.getFormatOptions() data: PhotoExportService.getFormatOptions()
+41 -28
View File
@@ -4,6 +4,7 @@ const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor'); const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
@@ -109,7 +110,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
// Upload photos for an event // Upload photos for an event
// Max file count is configurable via general settings // 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; let maxFilesPerUpload;
try { try {
maxFilesPerUpload = await getMaxFilesPerUpload(); maxFilesPerUpload = await getMaxFilesPerUpload();
@@ -177,20 +178,22 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Parse category_id to number if provided // Parse category_id to number if provided
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null; 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 photoType = 'individual'; // default
let categoryName = 'individual'; let categoryName = 'individual';
if (parsedCategoryId === 1 || 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'; photoType = 'collage';
categoryName = 'collages';
} else if (parsedCategoryId === 2 || category_id === 'individual') {
photoType = 'individual';
categoryName = 'individual';
} }
}
} else if (category_id === 'collage') {
// For backwards compatibility, accept string values // For backwards compatibility, accept string values
if (category_id === 'collage') {
photoType = 'collage'; photoType = 'collage';
categoryName = 'collages'; categoryName = 'collages';
} }
@@ -256,6 +259,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
path: relativePath, path: relativePath,
thumbnail_path: null, // Will generate after successful commit thumbnail_path: null, // Will generate after successful commit
type: photoType, type: photoType,
category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size // Use actual file size from stat 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 // 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 { try {
const { eventId, photoId } = req.params; 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) // 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 { try {
const { eventId, photoId } = req.params; const { eventId, photoId } = req.params;
const { category_id } = req.body; const { category_id } = req.body;
@@ -517,7 +521,15 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
.where({ id: photoId, event_id: eventId }) .where({ id: photoId, event_id: eventId })
.update(updateData); .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) { } catch (error) {
console.error('Error updating photo:', error); console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' }); 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 // 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 { try {
const { eventId } = req.params; const { eventId } = req.params;
const { photoIds } = req.body; const { photoIds } = req.body;
@@ -593,7 +605,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
}); });
// Bulk update photos // 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 { try {
const { eventId } = req.params; const { eventId } = req.params;
const { photoIds, updates } = req.body; const { photoIds, updates } = req.body;
@@ -651,7 +663,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
}); });
// Download a photo // 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 { try {
const { eventId, photoId } = req.params; 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 // 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 { try {
const { eventId } = req.params; const { eventId } = req.params;
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
let query = db('photos') let query = db('photos')
.where({ 'photos.event_id': eventId }) .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 // Filter by type (individual/collage) - category_id maps to type
if (category_id !== undefined) { 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 // Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`, thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type, type: photo.type,
category_id: photo.type, category_id: photo.category_id || photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
category_slug: photo.type, category_slug: photo.pc_slug || photo.type,
size: photo.size_bytes, size: photo.size_bytes,
uploaded_at: photo.uploaded_at, uploaded_at: photo.uploaded_at,
// Feedback data // Feedback data
@@ -767,7 +780,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
}); });
// Serve photo with admin authentication // 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 { try {
const { eventId, photoId } = req.params; const { eventId, photoId } = req.params;
@@ -804,7 +817,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
}); });
// Serve thumbnail with admin authentication // 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 { try {
const { eventId, photoId } = req.params; const { eventId, photoId } = req.params;
@@ -844,7 +857,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
}); });
// Debug endpoint to check photo existence // 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 { try {
const { eventId } = req.params; const { eventId } = req.params;
@@ -870,7 +883,7 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
// ============================================ // ============================================
// Initialize a chunked upload // 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 { try {
const { eventId } = req.params; const { eventId } = req.params;
const { filename, fileSize, mimeType, totalChunks } = req.body; const { filename, fileSize, mimeType, totalChunks } = req.body;
@@ -908,7 +921,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
}); });
// Upload a chunk // 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 { try {
const { uploadId, chunkIndex } = req.params; 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 // 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 { try {
const { eventId, uploadId } = req.params; const { eventId, uploadId } = req.params;
const { category_id } = req.body; const { category_id } = req.body;
@@ -971,7 +984,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req
}); });
// Get upload status // 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 { try {
const { uploadId } = req.params; const { uploadId } = req.params;
@@ -989,7 +1002,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, r
}); });
// Abort chunked upload // 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 { try {
const { uploadId } = req.params; const { uploadId } = req.params;
+148 -16
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { restoreService } = require('../services/restoreService'); const { restoreService } = require('../services/restoreService');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { body, query, validationResult } = require('express-validator'); const { body, query, validationResult } = require('express-validator');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { db } = require('../database/db'); const { db } = require('../database/db');
@@ -16,10 +17,36 @@ const fs = require('fs').promises;
// Apply admin authentication to all routes // Apply admin authentication to all routes
router.use(adminAuth); 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 * Get restore service status and history
*/ */
router.get('/status', async (req, res) => { router.get('/status', requirePermission('backup.view'), async (req, res) => {
try { try {
const limit = parseInt(req.query.limit) || 10; const limit = parseInt(req.query.limit) || 10;
const history = await restoreService.getRestoreHistory(limit); const history = await restoreService.getRestoreHistory(limit);
@@ -47,7 +74,7 @@ router.get('/status', async (req, res) => {
/** /**
* Validate restore request * Validate restore request
*/ */
router.post('/validate', [ router.post('/validate', requirePermission('backup.restore'), [
body('source').notEmpty().withMessage('Backup source is required'), body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'), body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'), body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
@@ -63,18 +90,38 @@ router.post('/validate', [
} }
try { try {
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
// Perform dry run validation // Perform dry run validation
const result = await restoreService.restore({ 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, dryRun: true,
force: false 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({ res.json({
success: true, success: true,
data: { data: {
validation: result.validation, validation: result.validation,
spaceCheck: result.spaceCheck, spaceCheck,
logs: result.logs logs: result.logs
} }
}); });
@@ -82,7 +129,7 @@ router.post('/validate', [
logger.error('Restore validation failed:', error); logger.error('Restore validation failed:', error);
res.status(400).json({ res.status(400).json({
success: false, success: false,
error: 'Restore validation failed', error: error.message || 'Restore validation failed',
logs: restoreService.restoreLog logs: restoreService.restoreLog
}); });
} }
@@ -91,7 +138,7 @@ router.post('/validate', [
/** /**
* Start restore operation * Start restore operation
*/ */
router.post('/start', [ router.post('/start', requirePermission('backup.restore'), [
body('source').notEmpty().withMessage('Backup source is required'), body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'), body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'), body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
@@ -135,19 +182,28 @@ router.post('/start', [
// Log restore attempt // Log restore attempt
logger.warn('Restore operation started', { logger.warn('Restore operation started', {
user: req.user.email, user: req.admin.email,
ip: req.ip, ip: req.ip,
restoreType: req.body.restoreType, restoreType: req.body.restoreType,
source: req.body.source source: req.body.source
}); });
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
// Start restore in background // Start restore in background
restoreService.restore({ 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, dryRun: false,
operator: { operator: {
type: 'manual', type: 'manual',
userId: req.user.id, userId: req.admin.id,
ip: req.ip ip: req.ip
} }
}).catch(error => { }).catch(error => {
@@ -160,9 +216,10 @@ router.post('/start', [
}); });
} catch (error) { } catch (error) {
logger.error('Failed to start restore:', error); logger.error('Failed to start restore:', error);
logger.error('Error stack:', error.stack);
res.status(500).json({ res.status(500).json({
success: false, 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 * Get current restore progress
*/ */
router.get('/progress', async (req, res) => { router.get('/progress', requirePermission('backup.view'), async (req, res) => {
try { try {
const progress = restoreService.getProgress(); const progress = restoreService.getProgress();
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
@@ -195,7 +252,7 @@ router.get('/progress', async (req, res) => {
/** /**
* Get restore run details * Get restore run details
*/ */
router.get('/run/:id', async (req, res) => { router.get('/run/:id', requirePermission('backup.view'), async (req, res) => {
try { try {
const run = await db('restore_runs') const run = await db('restore_runs')
.where('id', req.params.id) .where('id', req.params.id)
@@ -250,7 +307,7 @@ router.get('/run/:id', async (req, res) => {
/** /**
* Get restore run report * Get restore run report
*/ */
router.get('/run/:id/report', async (req, res) => { router.get('/run/:id/report', requirePermission('backup.view'), async (req, res) => {
try { try {
const run = await db('restore_runs') const run = await db('restore_runs')
.where('id', req.params.id) .where('id', req.params.id)
@@ -289,7 +346,7 @@ router.get('/run/:id/report', async (req, res) => {
/** /**
* List available backups for restore * List available backups for restore
*/ */
router.get('/available-backups', async (req, res) => { router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
try { try {
const backups = []; 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 * Get restore settings
*/ */
router.get('/settings', async (req, res) => { router.get('/settings', requirePermission('backup.view'), async (req, res) => {
try { try {
const settings = await getRestoreSettings(); const settings = await getRestoreSettings();
res.json({ res.json({
@@ -371,7 +503,7 @@ router.get('/settings', async (req, res) => {
/** /**
* Update restore settings * Update restore settings
*/ */
router.put('/settings', [ router.put('/settings', requirePermission('backup.restore'), [
body('restore_allow_force').optional().isBoolean(), body('restore_allow_force').optional().isBoolean(),
body('restore_require_pre_backup').optional().isBoolean(), body('restore_require_pre_backup').optional().isBoolean(),
body('restore_max_file_size_mb').optional().isInt({ min: 1 }), body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
+16 -15
View File
@@ -6,6 +6,7 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db'); const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { clearMaintenanceCache } = require('../middleware/maintenance'); const { clearMaintenanceCache } = require('../middleware/maintenance');
const { clearSettingsCache } = require('../services/rateLimitService'); const { clearSettingsCache } = require('../services/rateLimitService');
const { const {
@@ -92,7 +93,7 @@ const faviconUpload = multer({
}); });
// Get all settings // Get all settings
router.get('/', adminAuth, async (req, res) => { router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const settings = await db('app_settings').select('*'); const settings = await db('app_settings').select('*');
@@ -120,7 +121,7 @@ router.get('/', adminAuth, async (req, res) => {
}); });
// Get settings by type // Get settings by type
router.get('/:type', adminAuth, async (req, res) => { router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
const { type } = req.params; const { type } = req.params;
const settings = await db('app_settings') const settings = await db('app_settings')
@@ -151,7 +152,7 @@ router.get('/:type', adminAuth, async (req, res) => {
}); });
// Get password complexity settings for frontend // 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 { try {
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation'); const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
@@ -172,7 +173,7 @@ router.get('/password/complexity', adminAuth, async (req, res) => {
}); });
// Update branding settings // Update branding settings
router.put('/branding', adminAuth, async (req, res) => { router.put('/branding', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const { const {
company_name, company_name,
@@ -313,7 +314,7 @@ router.put('/branding', adminAuth, async (req, res) => {
}); });
// Upload logo // 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 { try {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'No logo file uploaded' }); 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 // 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 { try {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' }); 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 // Update theme settings
router.put('/theme', adminAuth, async (req, res) => { router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const themeSettings = req.body; const themeSettings = req.body;
@@ -474,7 +475,7 @@ router.put('/theme', adminAuth, async (req, res) => {
}); });
// Update general settings // Update general settings
router.put('/general', adminAuth, async (req, res) => { router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const settings = { ...req.body }; const settings = { ...req.body };
let uploadLimitTouched = false; let uploadLimitTouched = false;
@@ -573,7 +574,7 @@ router.put('/general', adminAuth, async (req, res) => {
}); });
// Update security settings // Update security settings
router.put('/security', adminAuth, async (req, res) => { router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const settings = req.body; const settings = req.body;
@@ -612,7 +613,7 @@ router.put('/security', adminAuth, async (req, res) => {
}); });
// Update analytics settings // Update analytics settings
router.put('/analytics', adminAuth, async (req, res) => { router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try { try {
const settings = req.body; const settings = req.body;
@@ -649,7 +650,7 @@ router.put('/analytics', adminAuth, async (req, res) => {
}); });
// Get storage info // Get storage info
router.get('/storage/info', adminAuth, async (req, res) => { router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
// Get total storage used // Get total storage used
const totalStorage = await db('photos') const totalStorage = await db('photos')
@@ -877,7 +878,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
}); });
// Upload favicon endpoint // 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 { try {
if (!req.file) { if (!req.file) {
return res.status(400).json({ error: 'No favicon file provided' }); 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 // 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_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_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'), 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 // 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 { try {
const defaults = await getDefaultPublicSitePayload(); const defaults = await getDefaultPublicSitePayload();
@@ -1000,7 +1001,7 @@ router.get('/public-site/default', adminAuth, async (req, res) => {
}); });
// Reset public site template to defaults // 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 { try {
const entries = [ const entries = [
{ {
+4 -3
View File
@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const { db, withRetry } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
// Get system version // Get system version
router.get('/version', adminAuth, async (req, res) => { router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
// Read backend version from package.json // Read backend version from package.json
let backendVersion = '1.0.0'; let backendVersion = '1.0.0';
@@ -35,7 +36,7 @@ router.get('/version', adminAuth, async (req, res) => {
}); });
// Get comprehensive system status // Get comprehensive system status
router.get('/status', adminAuth, async (req, res) => { router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
// Database size - check if PostgreSQL or SQLite // Database size - check if PostgreSQL or SQLite
let dbSize = 0; let dbSize = 0;
@@ -170,7 +171,7 @@ router.get('/status', adminAuth, async (req, res) => {
}); });
// Get database statistics // Get database statistics
router.get('/database', adminAuth, async (req, res) => { router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => {
try { try {
// Get table info // Get table info
const tables = [ const tables = [
+5 -4
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { db } = require('../database/db'); const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth'); const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail } = require('../services/imageProcessor'); const { generateThumbnail } = require('../services/imageProcessor');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
@@ -10,7 +11,7 @@ const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Get thumbnail settings // Get thumbnail settings
router.get('/settings', adminAuth, async (req, res) => { router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
try { try {
const settings = await db('app_settings') const settings = await db('app_settings')
.whereIn('key', [ .whereIn('key', [
@@ -42,7 +43,7 @@ router.get('/settings', adminAuth, async (req, res) => {
}); });
// Update thumbnail settings // Update thumbnail settings
router.put('/settings', adminAuth, async (req, res) => { router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try { try {
const { width, height, fit, quality, format } = req.body; 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 // Regenerate all thumbnails with new settings
router.post('/regenerate', adminAuth, async (req, res) => { router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try { try {
const { eventId } = req.body; // Optional: regenerate for specific event only const { eventId } = req.body; // Optional: regenerate for specific event only
@@ -164,7 +165,7 @@ router.post('/regenerate', adminAuth, async (req, res) => {
}); });
// Get regeneration status // Get regeneration status
router.get('/regenerate/status', adminAuth, async (req, res) => { router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try { try {
// Count photos with and without thumbnails // Count photos with and without thumbnails
const totalPhotos = await db('photos').count('id as count').first(); const totalPhotos = await db('photos').count('id as count').first();
+194
View File
@@ -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;
+17 -4
View File
@@ -70,9 +70,16 @@ router.post('/admin/login', [
logger.warn('Suspicious login pattern detected', { username, ipAddress }); logger.warn('Suspicious login pattern detected', { username, ipAddress });
} }
// Fetch admin with role information
const admin = await db('admin_users') const admin = await db('admin_users')
.where({ username }) .leftJoin('roles', 'roles.id', 'admin_users.role_id')
.orWhere({ email: username }) .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(); .first();
// Use generic error to prevent user enumeration // Use generic error to prevent user enumeration
@@ -95,11 +102,12 @@ router.post('/admin/login', [
last_login_ip: ipAddress last_login_ip: ipAddress
}); });
// Generate token with additional claims // Generate token with additional claims including role
const token = jwt.sign({ const token = jwt.sign({
id: admin.id, id: admin.id,
username: admin.username, username: admin.username,
type: 'admin', type: 'admin',
role: admin.role_name, // Add role to JWT
ip: ipAddress, ip: ipAddress,
loginTime: Date.now() loginTime: Date.now()
}, process.env.JWT_SECRET, { }, process.env.JWT_SECRET, {
@@ -109,13 +117,18 @@ router.post('/admin/login', [
setAdminAuthCookie(res, token); setAdminAuthCookie(res, token);
// Include role in response
res.json({ res.json({
token, token,
user: { user: {
id: admin.id, id: admin.id,
username: admin.username, username: admin.username,
email: admin.email, 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) { } catch (error) {
+50 -11
View File
@@ -728,15 +728,15 @@ async function runBackupInternal() {
} }
const schemaVersion = await getCurrentSchemaVersion(); const schemaVersion = await getCurrentSchemaVersion();
const [insertedId] = await db('backup_runs').insert({ const insertResult = await db('backup_runs').insert({
started_at: startTime, started_at: startTime,
status: 'running', status: 'running',
backup_type: 'scheduled', backup_type: 'scheduled',
app_version: packageJson.version, app_version: packageJson.version,
node_version: process.version, node_version: process.version,
db_schema_version: schemaVersion db_schema_version: schemaVersion
}); }).returning('id');
runId = insertedId; runId = insertResult[0]?.id || insertResult[0];
const files = await service.getFilesToBackup(config.backup_include_archived); const files = await service.getFilesToBackup(config.backup_include_archived);
logger.info(`Found ${files.length} files to check for backup`); 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_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null, manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null,
statistics: JSON.stringify({ 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, totalFilesChecked: files.length,
filesBackedUp: result.backedUpCount, filesBackedUp: result.backedUpCount,
totalSize: result.backedUpSize, totalSize: result.backedUpSize,
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0, averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0
destination: destinationType
}) })
}); });
@@ -927,22 +933,54 @@ async function triggerManualBackup() {
async function getBackupStatus(limit = 10) { async function getBackupStatus(limit = 10) {
try { try {
const runs = await db('backup_runs') const rawRuns = await db('backup_runs')
.orderBy('started_at', 'desc') .orderBy('started_at', 'desc')
.limit(limit); .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]; const lastRun = runs[0];
let manifestValid = false; let manifestValid = false;
if (lastRun && lastRun.manifest_path) { if (lastRun && lastRun.manifest_path) {
try { try {
const manifest = await backupManifest.loadManifest(lastRun.manifest_path); // Use validateBackupManifest which handles both local and S3 paths
if (backupManifest.validateManifest) { const result = await validateBackupManifest(lastRun.manifest_path);
backupManifest.validateManifest(manifest); manifestValid = result.valid;
if (!result.valid) {
logger.warn('Manifest validation failed:', result.error);
} }
manifestValid = true;
} catch (error) { } 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'), isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
lastRun: lastRun ? { ...lastRun, manifestValid } : null, lastRun: lastRun ? { ...lastRun, manifestValid } : null,
recentRuns: runs, recentRuns: runs,
recentBackups: runs, // Alias for frontend compatibility
nextScheduledRun: getNextScheduledRun() nextScheduledRun: getNextScheduledRun()
}; };
} catch (error) { } catch (error) {
+3
View File
@@ -153,6 +153,9 @@ async function processTemplate(template, variables, language = 'en') {
if (processedVariables.archive_date) { if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language); 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) // Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) { if (processedVariables.welcome_message) {
+42 -14
View File
@@ -75,14 +75,15 @@ class RestoreService {
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) }); this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
// Create restore run record // Create restore run record
const [runId] = await db('restore_runs').insert({ const result = await db('restore_runs').insert({
started_at: startTime, started_at: startTime,
status: 'running', status: 'running',
restore_type: options.restoreType, restore_type: options.restoreType,
source: options.source, source: options.source,
manifest_path: options.manifestPath, manifest_path: options.manifestPath,
is_dry_run: options.dryRun || false is_dry_run: options.dryRun || false
}); }).returning('id');
const runId = Array.isArray(result) ? (result[0]?.id || result[0]) : result;
restoreRun = { id: runId }; restoreRun = { id: runId };
@@ -105,7 +106,8 @@ class RestoreService {
if (validation.warnings.length > 0) { if (validation.warnings.length > 0) {
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings }); 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(', ')}`); 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 * Check available disk space
*/ */
async checkDiskSpace(manifest, options) { async checkDiskSpace(manifest, options) {
const { statvfs } = require('fs');
const statvfsAsync = promisify(statvfs);
try { try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); 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) // Calculate required space (with 20% buffer)
let requiredBytes = 0; let requiredBytes = 0;
if (options.restoreType === 'full' || options.restoreType === 'files') { 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') { 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 // Add space for pre-restore backup
if (!options.skipPreBackup) { if (!options.skipPreBackup) {
try {
const currentUsage = await this.calculateCurrentStorageUsage(); const currentUsage = await this.calculateCurrentStorageUsage();
requiredBytes += currentUsage * 1.1; // 10% buffer for backup requiredBytes += currentUsage * 1.1; // 10% buffer for backup
} catch (e) {
// Ignore errors calculating current usage
}
} }
return { return {
@@ -434,11 +462,11 @@ class RestoreService {
}; };
} catch (error) { } catch (error) {
// Fallback for systems without statvfs // Fallback for any errors
this.log('warn', 'Could not check disk space', { error: error.message }); this.log('warn', 'Could not check disk space', { error: error.message });
return { return {
hasEnoughSpace: true, // Assume we have space if we can't check hasEnoughSpace: true, // Assume we have space if we can't check
availableBytes: 0, availableBytes: null,
requiredBytes: 0, requiredBytes: 0,
availableFormatted: 'Unknown', availableFormatted: 'Unknown',
requiredFormatted: 'Unknown' requiredFormatted: 'Unknown'
+16 -6
View File
@@ -76,12 +76,22 @@ class S3StorageAdapter extends stream.EventEmitter {
// Add custom endpoint if provided (for S3-compatible services) // Add custom endpoint if provided (for S3-compatible services)
if (this.config.endpoint) { if (this.config.endpoint) {
s3Config.endpoint = this.config.endpoint; let endpoint = this.config.endpoint;
// For MinIO and other S3-compatible services
if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) { // Only add protocol if endpoint doesn't already have one
s3Config.endpoint = `https://${this.config.endpoint}`; const hasProtocol = endpoint.startsWith('http://') || endpoint.startsWith('https://');
} else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) { if (!hasProtocol) {
s3Config.endpoint = `http://${this.config.endpoint}`; // 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');
} }
} }
@@ -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<object>} 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<object>} 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<object[]>}
*/
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<object>}
*/
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<object>} 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<object>} 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<object[]>}
*/
async function getAllRoles() {
return db('roles')
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
.orderBy('priority', 'desc');
}
/**
* Get pending invitations
* @returns {Promise<object[]>}
*/
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<object|null>} 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
};
+5 -5
View File
@@ -30,13 +30,13 @@ COPY . .
# Build the application # Build the application
RUN npm run build RUN npm run build
# Production stage (use Alpine with patched libpng) # Production stage (use Alpine with patched libpng/c-ares)
FROM nginx:1.27-alpine3.21 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 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 # Install runtime dependencies
RUN apk add --no-cache curl RUN apk add --no-cache curl
+50 -61
View File
@@ -1088,9 +1088,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.39.1", "version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1604,9 +1604,9 @@
] ]
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.90.11", "version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==", "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1614,12 +1614,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.90.11", "version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==", "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.11" "@tanstack/query-core": "5.90.16"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1688,9 +1688,9 @@
} }
}, },
"node_modules/@testing-library/react": { "node_modules/@testing-library/react": {
"version": "16.3.0", "version": "16.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2935,9 +2935,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.22", "version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==", "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2955,10 +2955,9 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"browserslist": "^4.27.0", "browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001760",
"fraction.js": "^5.3.4", "fraction.js": "^5.3.4",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0" "postcss-value-parser": "^4.2.0"
}, },
@@ -2991,9 +2990,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.31", "version": "2.9.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", "integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3038,9 +3037,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.0", "version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3059,11 +3058,11 @@
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.249", "electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27", "node-releases": "^2.0.27",
"update-browserslist-db": "^1.1.4" "update-browserslist-db": "^1.2.0"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@@ -3116,9 +3115,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001757", "version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3522,9 +3521,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.0", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==", "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@@ -3545,9 +3544,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.262", "version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -3680,9 +3679,9 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "9.39.1", "version": "9.39.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -3693,7 +3692,7 @@
"@eslint/config-helpers": "^0.4.2", "@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0", "@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.1", "@eslint/js": "9.39.2",
"@eslint/plugin-kit": "^0.4.1", "@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6", "@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
@@ -3754,9 +3753,9 @@
} }
}, },
"node_modules/eslint-plugin-react-refresh": { "node_modules/eslint-plugin-react-refresh": {
"version": "0.4.24", "version": "0.4.26",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
@@ -4325,9 +4324,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "25.6.3", "version": "25.7.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
"integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==", "integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -5022,16 +5021,6 @@
"node": ">=0.10.0" "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": { "node_modules/nwsapi": {
"version": "2.2.22", "version": "2.2.22",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
@@ -6517,9 +6506,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.4", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
+7 -1
View File
@@ -23,8 +23,10 @@ import {
BrandingPage, BrandingPage,
SettingsPage, SettingsPage,
BackupManagement, BackupManagement,
CMSPage CMSPage,
UserManagementPage
} from './pages/admin'; } from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common'; import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -128,10 +130,14 @@ function App() {
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="backup" element={<BackupManagement />} /> <Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPage />} /> <Route path="cms" element={<CMSPage />} />
<Route path="users" element={<UserManagementPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} /> <Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route> </Route>
</Route> </Route>
{/* Public invitation acceptance page */}
<Route path="/invite/:token" element={<AcceptInvitePage />} />
{/* Public legal pages */} {/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} /> <Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} /> <Route path="/datenschutz" element={<LegalPage />} />
@@ -1,11 +1,13 @@
import React from 'react'; import React from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { AdminAuthProvider } from '../../contexts'; import { AdminAuthProvider, PermissionsProvider } from '../../contexts';
export const AdminAuthWrapper: React.FC = () => { export const AdminAuthWrapper: React.FC = () => {
return ( return (
<AdminAuthProvider> <AdminAuthProvider>
<PermissionsProvider>
<Outlet /> <Outlet />
</PermissionsProvider>
</AdminAuthProvider> </AdminAuthProvider>
); );
}; };
+26 -18
View File
@@ -10,12 +10,14 @@ import {
X, X,
Palette, Palette,
FileText, FileText,
HardDrive HardDrive,
Users
} from 'lucide-react'; } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo'; import { VersionInfo } from './VersionInfo';
import { usePermissions } from '../../contexts/PermissionsContext';
interface AdminSidebarProps { interface AdminSidebarProps {
isOpen: boolean; isOpen: boolean;
@@ -26,23 +28,32 @@ interface NavItem {
nameKey: string; nameKey: string;
href: string; href: string;
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
permission?: string;
} }
const navigation: NavItem[] = [ const navigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard }, { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar }, { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive }, { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 }, { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view' },
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail }, { nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette }, { nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings }, { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive }, { nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText }, { 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<AdminSidebarProps> = ({ isOpen, onClose }) => { export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation(); 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 ( return (
<div <div
@@ -66,7 +77,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0"> <nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
{navigation.map((item) => { {filteredNavigation.map((item) => {
const isActive = location.pathname === item.href || const isActive = location.pathname === item.href ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href)); (item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
@@ -90,7 +101,8 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
})} })}
</nav> </nav>
{/* Bottom section - sticky to bottom */} {/* Bottom section - sticky to bottom (only for users with settings.view permission) */}
{hasPermission('settings.view') && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{/* Version Info */} {/* Version Info */}
<VersionInfo /> <VersionInfo />
@@ -98,6 +110,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
{/* Storage Info */} {/* Storage Info */}
<StorageInfo /> <StorageInfo />
</div> </div>
)}
</div> </div>
</div> </div>
); );
@@ -111,14 +124,9 @@ const StorageInfo: React.FC = () => {
refetchInterval: 60000 // Refresh every minute refetchInterval: 60000 // Refresh every minute
}); });
// Don't render anything while loading or if data failed to load
if (!storageInfo) { if (!storageInfo) {
return ( return null;
<div className="p-4 border-t border-neutral-200">
<div className="bg-neutral-100 rounded-lg p-3">
<div className="h-12 animate-pulse bg-neutral-200 rounded" />
</div>
</div>
);
} }
const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1; const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
@@ -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<PermissionGateProps> = ({
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';
@@ -500,13 +500,18 @@ export const RestoreWizard = () => {
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.required')}:</span> <span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
<span className="font-medium">{formatBytes(validationResult.spaceCheck.required)}</span> <span className="font-medium">
{validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)}
</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.available')}:</span> <span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
<span className="font-medium">{formatBytes(validationResult.spaceCheck.available)}</span> <span className="font-medium">
{validationResult.spaceCheck.availableFormatted ||
(validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))}
</span>
</div> </div>
{!validationResult.spaceCheck.sufficient && ( {validationResult.spaceCheck.sufficient === false && (
<p className="text-red-600 text-xs mt-2"> <p className="text-red-600 text-xs mt-2">
<AlertCircle className="inline h-3 w-3 mr-1" /> <AlertCircle className="inline h-3 w-3 mr-1" />
{t('backup.restore.confirmation.spaceCheck.insufficient')} {t('backup.restore.confirmation.spaceCheck.insufficient')}
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react'; 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 { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types'; import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
// import { settingsService } from '../../services/settings.service'; // import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify'; // import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -16,6 +17,10 @@ interface ThemeCustomizerEnhancedProps {
hideActions?: boolean; hideActions?: boolean;
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void; onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
isApplying?: boolean; isApplying?: boolean;
// CSS Template props
cssTemplates?: EnabledTemplate[];
cssTemplateId?: number | null;
onCssTemplateChange?: (templateId: number | null) => void;
} }
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = { const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
@@ -38,7 +43,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
showGalleryLayouts = true, showGalleryLayouts = true,
hideActions = false, hideActions = false,
onApply, onApply,
isApplying = false isApplying = false,
cssTemplates,
cssTemplateId,
onCssTemplateChange
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value); const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
@@ -566,11 +574,67 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div> </div>
</Card> </Card>
{/* Custom CSS */} {/* CSS Template Selector - only show if templates are provided */}
{cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<FileCode className="w-5 h-5" />
{t('branding.cssTemplate', 'CSS Template')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
{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.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* No template option */}
<button
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{t('branding.noTemplate', 'No Template')}</span>
{!cssTemplateId && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 mt-1 block">
{t('branding.noTemplateDescription', 'Use only theme settings without a CSS template')}
</span>
</button>
{/* Template options */}
{cssTemplates.map((template) => (
<button
key={template.id}
onClick={() => 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'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{template.name}</span>
{cssTemplateId === template.id && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 mt-1 block">
{t('branding.templateSlot', 'Slot {{slot}}', { slot: template.slot_number })}
</span>
</button>
))}
</div>
</Card>
)}
{/* Event-specific Custom CSS */}
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2"> <h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Code className="w-5 h-5" /> <Code className="w-5 h-5" />
{t('branding.customCSS')} {t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
</h3> </h3>
{/* Collapsible Instructions Panel */} {/* Collapsible Instructions Panel */}
@@ -4,13 +4,15 @@ import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced'; import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview'; import { GalleryPreview } from './GalleryPreview';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types'; import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface ThemeEditorModalProps { interface ThemeEditorModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSave: (theme: ThemeConfig, presetName: string) => void; onSave: (theme: ThemeConfig, presetName: string, cssTemplateId: number | null) => void;
currentTheme: ThemeConfig | string; currentTheme: ThemeConfig | string;
currentCssTemplateId?: number | null;
eventName: string; eventName: string;
} }
@@ -28,12 +30,29 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
onClose, onClose,
onSave, onSave,
currentTheme, currentTheme,
currentCssTemplateId,
eventName eventName
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config); const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
const [presetName, setPresetName] = useState<string>('default'); const [presetName, setPresetName] = useState<string>('default');
const [previewLayout, setPreviewLayout] = useState<GalleryLayoutType | undefined>(undefined); const [previewLayout, setPreviewLayout] = useState<GalleryLayoutType | undefined>(undefined);
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
const [cssTemplateId, setCssTemplateId] = useState<number | null>(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(() => { useEffect(() => {
if (currentTheme) { if (currentTheme) {
@@ -82,7 +101,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
}; };
const handleSave = () => { const handleSave = () => {
onSave(theme, presetName); onSave(theme, presetName, cssTemplateId);
onClose(); onClose();
}; };
@@ -128,6 +147,9 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
isPreviewMode={true} isPreviewMode={true}
showGalleryLayouts={true} showGalleryLayouts={true}
hideActions={true} hideActions={true}
cssTemplates={cssTemplates}
cssTemplateId={cssTemplateId}
onCssTemplateChange={setCssTemplateId}
/> />
</div> </div>
@@ -456,7 +456,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onClick={() => { onClick={() => {
setShowFeedback(!showFeedback); 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" aria-label="Toggle feedback"
title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`} title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`}
> >
@@ -164,7 +164,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
}); });
} catch (_) {} } 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" title="Like photo"
aria-pressed={likedIds.has(currentPhoto.id)} aria-pressed={likedIds.has(currentPhoto.id)}
> >
@@ -176,7 +176,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onOpenPhotoWithFeedback?.(currentIndex); }} 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" title="Comment"
aria-label="Comment on photo" aria-label="Comment on photo"
> >
@@ -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<void>;
}
const PermissionsContext = createContext<PermissionsContextType | undefined>(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<PermissionsProviderProps> = ({ children }) => {
const { isAuthenticated } = useAdminAuth();
const [permissions, setPermissions] = useState<string[]>([]);
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<AdminPermissions>('/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 (
<PermissionsContext.Provider
value={{
permissions,
role,
hasPermission,
hasAnyPermission,
hasAllPermissions,
isSuperAdmin,
isLoading,
refresh,
}}
>
{children}
</PermissionsContext.Provider>
);
};
export { PermissionsContext };
+1
View File
@@ -4,3 +4,4 @@ export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext'; export type { ThemeConfig, EventTheme } from './ThemeContext';
export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext'; export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
export { PermissionsProvider, usePermissions, PermissionsContext } from './PermissionsContext';
@@ -48,7 +48,7 @@ export const ImageSecurityTab: React.FC = () => {
const { data: fetchedSettings, isLoading, error } = useQuery({ const { data: fetchedSettings, isLoading, error } = useQuery({
queryKey: ['image-security-settings'], queryKey: ['image-security-settings'],
queryFn: async () => { queryFn: async () => {
const response = await api.get('/api/admin/image-security/settings'); const response = await api.get('/admin/image-security/settings');
return response.data; return response.data;
}, },
}); });
@@ -66,7 +66,7 @@ export const ImageSecurityTab: React.FC = () => {
// Save mutation // Save mutation
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: async (newSettings: ImageSecuritySettings) => { 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; return response.data;
}, },
onSuccess: () => { onSuccess: () => {
+1
View File
@@ -2,3 +2,4 @@ export * from './useSessionTimeout';
export * from './useOnClickOutside'; export * from './useOnClickOutside';
export * from './useLocalizedDate'; export * from './useLocalizedDate';
export * from './useLocalizedTimeAgo'; export * from './useLocalizedTimeAgo';
export * from './usePermission';
+23
View File
@@ -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);
}
+162 -1
View File
@@ -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": "[email protected]",
"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": { "common": {
"loading": "Wird geladen...", "loading": "Wird geladen...",
"error": "Fehler", "error": "Fehler",
@@ -84,7 +158,8 @@
"analytics": "Analytik", "analytics": "Analytik",
"emailSettings": "E-Mail-Einstellungen", "emailSettings": "E-Mail-Einstellungen",
"backup": "Backup & Wiederherstellung", "backup": "Backup & Wiederherstellung",
"cmsPages": "CMS-Seiten" "cmsPages": "CMS-Seiten",
"users": "Benutzer"
}, },
"backup": { "backup": {
"external": { "external": {
@@ -153,6 +228,10 @@
"title": "Backup nicht konfiguriert", "title": "Backup nicht konfiguriert",
"message": "Bitte konfigurieren Sie die Backup-Einstellungen im Tab \"Konfiguration\", bevor Sie Backups ausführen." "message": "Bitte konfigurieren Sie die Backup-Einstellungen im Tab \"Konfiguration\", bevor Sie Backups ausführen."
}, },
"actions": {
"runBackupNow": "Backup jetzt starten",
"running": "Läuft..."
},
"coverage": { "coverage": {
"title": "Backup-Abdeckung", "title": "Backup-Abdeckung",
"database": "Datenbank", "database": "Datenbank",
@@ -1282,6 +1361,88 @@
"admin_logout": "Admin {{actorName}} abgemeldet", "admin_logout": "Admin {{actorName}} abgemeldet",
"system_activity": "Systemaktivität: {{type}}", "system_activity": "Systemaktivität: {{type}}",
"unknown": "Unbekannte Aktivität" "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": { "errors": {
+162 -1
View File
@@ -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": "[email protected]",
"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": { "common": {
"loading": "Loading...", "loading": "Loading...",
"error": "Error", "error": "Error",
@@ -84,7 +158,8 @@
"analytics": "Analytics", "analytics": "Analytics",
"emailSettings": "Email Settings", "emailSettings": "Email Settings",
"backup": "Backup & Restore", "backup": "Backup & Restore",
"cmsPages": "CMS Pages" "cmsPages": "CMS Pages",
"users": "Users"
}, },
"archives": { "archives": {
"title": "Archives", "title": "Archives",
@@ -1015,6 +1090,88 @@
"admin_logout": "Admin {{actorName}} logged out", "admin_logout": "Admin {{actorName}} logged out",
"system_activity": "System activity: {{type}}", "system_activity": "System activity: {{type}}",
"unknown": "Unknown activity" "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": { "errors": {
@@ -1233,6 +1390,10 @@
"title": "Backup Not Configured", "title": "Backup Not Configured",
"message": "Please configure backup settings in the Configuration tab before running backups." "message": "Please configure backup settings in the Configuration tab before running backups."
}, },
"actions": {
"runBackupNow": "Run Backup Now",
"running": "Running..."
},
"coverage": { "coverage": {
"title": "Backup Coverage", "title": "Backup Coverage",
"database": "Database", "database": "Database",
@@ -58,6 +58,7 @@ import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service'; import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../services/photos.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 { 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'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
const resolveShareLink = (link: string): string => { const resolveShareLink = (link: string): string => {
@@ -141,6 +142,7 @@ export const EventDetailsPage: React.FC = () => {
type EditFormState = { type EditFormState = {
welcome_message: string; welcome_message: string;
color_theme: string; color_theme: string;
css_template_id: number | null;
expires_at: string; expires_at: string;
allow_user_uploads: boolean; allow_user_uploads: boolean;
upload_category_id: number | null; upload_category_id: number | null;
@@ -164,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
const [editForm, setEditForm] = useState<EditFormState>({ const [editForm, setEditForm] = useState<EditFormState>({
welcome_message: '', welcome_message: '',
color_theme: '', color_theme: '',
css_template_id: null,
expires_at: '', expires_at: '',
allow_user_uploads: false, allow_user_uploads: false,
upload_category_id: null, upload_category_id: null,
@@ -207,6 +210,16 @@ export const EventDetailsPage: React.FC = () => {
const [showRenameDialog, setShowRenameDialog] = useState(false); const [showRenameDialog, setShowRenameDialog] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null); const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default'); const [currentPresetName, setCurrentPresetName] = useState<string>('default');
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
// 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 // Photo filters state
const [photoFilters, setPhotoFilters] = useState<PhotoFilterParams>({ const [photoFilters, setPhotoFilters] = useState<PhotoFilterParams>({
@@ -375,6 +388,7 @@ export const EventDetailsPage: React.FC = () => {
setEditForm({ setEditForm({
welcome_message: event.welcome_message || '', welcome_message: event.welcome_message || '',
color_theme: event.color_theme || '', color_theme: event.color_theme || '',
css_template_id: event.css_template_id || null,
expires_at: format(safeParseDate(event.expires_at), 'yyyy-MM-dd'), expires_at: format(safeParseDate(event.expires_at), 'yyyy-MM-dd'),
allow_user_uploads: event.allow_user_uploads || false, allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null, upload_category_id: event.upload_category_id || null,
@@ -474,6 +488,7 @@ export const EventDetailsPage: React.FC = () => {
expires_at: editForm.expires_at, expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads, allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password, require_password: editForm.require_password,
css_template_id: editForm.css_template_id,
// Download protection settings // Download protection settings
protection_level: editForm.protection_level, protection_level: editForm.protection_level,
disable_right_click: editForm.disable_right_click, disable_right_click: editForm.disable_right_click,
@@ -1357,6 +1372,9 @@ export const EventDetailsPage: React.FC = () => {
isPreviewMode={true} isPreviewMode={true}
showGalleryLayouts={true} showGalleryLayouts={true}
hideActions={true} hideActions={true}
cssTemplates={cssTemplates}
cssTemplateId={editForm.css_template_id}
onCssTemplateChange={(templateId) => setEditForm(prev => ({ ...prev, css_template_id: templateId }))}
/> />
</Card> </Card>
)} )}
@@ -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<CreateInvitationModalProps> = ({
isOpen,
onClose,
onSubmit,
roles,
isLoading,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [roleId, setRoleId] = useState<number | ''>('');
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('userManagement.createInvitation')}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.email')}
</label>
<Input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setErrors((prev) => ({ ...prev, email: undefined }));
}}
placeholder={t('userManagement.emailPlaceholder')}
disabled={isLoading}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.role')}
</label>
<select
value={roleId}
onChange={(e) => {
setRoleId(e.target.value ? Number(e.target.value) : '');
setErrors((prev) => ({ ...prev, role: undefined }));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
<option value="">{t('userManagement.selectRole')}</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.displayName}
</option>
))}
</select>
{errors.role && (
<p className="mt-1 text-sm text-red-600">{errors.role}</p>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isLoading}
leftIcon={<Mail className="w-4 h-4" />}
>
{t('userManagement.sendInvitation')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
// 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<EditUserModalProps> = ({
isOpen,
onClose,
onSubmit,
user,
roles,
isLoading,
}) => {
const { t } = useTranslation();
const [roleId, setRoleId] = useState<number | ''>('');
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('userManagement.editUser')}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<div className="mb-4 p-3 bg-neutral-50 rounded-lg">
<p className="text-sm text-neutral-600">
{t('userManagement.editingUser')}: <strong>{user.username}</strong>
</p>
<p className="text-sm text-neutral-500">{user.email}</p>
</div>
<form onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.role')}
</label>
<select
value={roleId}
onChange={(e) => setRoleId(e.target.value ? Number(e.target.value) : '')}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
<option value="">{t('userManagement.selectRole')}</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.displayName}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isLoading}
leftIcon={<Edit className="w-4 h-4" />}
>
{t('userManagement.saveChanges')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
// 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<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
isLoading,
variant = 'danger',
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<div
className={`p-2 rounded-full ${
variant === 'danger' ? 'bg-red-100' : 'bg-amber-100'
}`}
>
<AlertTriangle
className={`w-5 h-5 ${
variant === 'danger' ? 'text-red-600' : 'text-amber-600'
}`}
/>
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900">{title}</h2>
<p className="text-sm text-neutral-600 mt-1">{message}</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={onConfirm}
isLoading={isLoading}
className={
variant === 'danger'
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: ''
}
>
{confirmText}
</Button>
</div>
</div>
</Card>
</div>
);
};
export const UserManagementPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// State
const [activeTab, setActiveTab] = useState<TabType>('users');
const [searchTerm, setSearchTerm] = useState('');
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false);
const [showEditUserModal, setShowEditUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(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 (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('userManagement.loading')} />
</div>
</div>
);
}
// Error state
if (usersError || invitationsError) {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<div className="text-center py-12">
<p className="text-red-600">{t('userManagement.loadError')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
{t('common.tryAgain')}
</Button>
</div>
</div>
);
}
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 (
<div>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => setShowCreateInvitationModal(true)}
>
{t('userManagement.inviteUser')}
</Button>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.totalUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.length || 0}
</p>
</div>
<Users className="w-8 h-8 text-primary-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.activeUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.filter((u) => u.isActive).length || 0}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.pendingInvitations')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{invitations?.length || 0}
</p>
</div>
<Mail className="w-8 h-8 text-blue-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.inactiveUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.filter((u) => !u.isActive).length || 0}
</p>
</div>
<XCircle className="w-8 h-8 text-neutral-400" />
</div>
</Card>
</div>
{/* Tab Navigation */}
<div className="border-b border-neutral-200 mb-6">
<nav className="-mb-px flex gap-6">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => 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}
<span
className={`px-2 py-0.5 text-xs rounded-full ${
activeTab === tab.key
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-600'
}`}
>
{tab.count}
</span>
</button>
))}
</nav>
</div>
{/* Search */}
<Card padding="sm" className="mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
type="text"
placeholder={
activeTab === 'users'
? t('userManagement.searchUsersPlaceholder')
: t('userManagement.searchInvitationsPlaceholder')
}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</Card>
{/* Users Tab Content */}
{activeTab === 'users' && (
<Card className="overflow-visible">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.lastLogin')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
{searchTerm
? t('userManagement.noUsersFound')
: t('userManagement.noUsers')}
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
<span className="text-primary-700 font-medium text-sm">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">
{user.username}
</p>
<p className="text-xs text-neutral-500">{user.email}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(
user.roleName || ''
)}`}
>
<Shield className="w-3 h-3" />
{user.roleDisplayName || user.roleName || t('userManagement.noRole')}
</span>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-500'
}`}
>
{user.isActive
? t('userManagement.status.active')
: t('userManagement.status.inactive')}
</span>
</td>
<td className="px-6 py-4">
{user.lastLogin ? (
<div className="flex items-center gap-1 text-sm text-neutral-600">
<Clock className="w-4 h-4" />
{formatDistanceToNow(parseISO(user.lastLogin), {
addSuffix: true,
})}
</div>
) : (
<span className="text-sm text-neutral-400">
{t('userManagement.neverLoggedIn')}
</span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => 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')}
>
<Edit className="w-4 h-4" />
</button>
{user.isActive && (
<button
onClick={() => 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')}
>
<UserX className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Invitations Tab Content */}
{activeTab === 'invitations' && (
<Card className="overflow-visible">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.invitedBy')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.expires')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredInvitations.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
{searchTerm
? t('userManagement.noInvitationsFound')
: t('userManagement.noInvitations')}
</td>
</tr>
) : (
filteredInvitations.map((invitation) => {
const isExpired = isPast(parseISO(invitation.expiresAt));
return (
<tr key={invitation.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Mail className="w-5 h-5 text-blue-600" />
</div>
<p className="text-sm font-medium text-neutral-900">
{invitation.email}
</p>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(
invitation.roleName || ''
)}`}
>
<Shield className="w-3 h-3" />
{invitation.roleName}
</span>
</td>
<td className="px-6 py-4 text-sm text-neutral-600">
{invitation.invitedBy || '-'}
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 text-sm ${
isExpired ? 'text-red-600' : 'text-neutral-600'
}`}
>
<Clock className="w-4 h-4" />
{isExpired
? t('userManagement.expired')
: formatDistanceToNow(parseISO(invitation.expiresAt), {
addSuffix: true,
})}
</span>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => 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')}
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Create Invitation Modal */}
<CreateInvitationModal
isOpen={showCreateInvitationModal}
onClose={() => setShowCreateInvitationModal(false)}
onSubmit={handleCreateInvitation}
roles={roles || []}
isLoading={createInvitationMutation.isPending}
/>
{/* Edit User Modal */}
<EditUserModal
isOpen={showEditUserModal}
onClose={() => {
setShowEditUserModal(false);
setSelectedUser(null);
}}
onSubmit={handleUpdateUser}
user={selectedUser}
roles={roles || []}
isLoading={updateUserMutation.isPending}
/>
{/* Confirmation Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
onClose={() => 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'}
/>
)}
</div>
);
};
UserManagementPage.displayName = 'UserManagementPage';
+1
View File
@@ -11,3 +11,4 @@ export { SettingsPage } from './SettingsPage';
export { CMSPage } from './CMSPage'; export { CMSPage } from './CMSPage';
export { BackupManagement } from './BackupManagement'; export { BackupManagement } from './BackupManagement';
export { EventFeedbackPage } from './EventFeedbackPage'; export { EventFeedbackPage } from './EventFeedbackPage';
export { UserManagementPage } from './UserManagementPage';
@@ -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<Record<string, string>>({});
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
// Validate invitation token
const {
data: invitation,
isLoading: isValidating,
error: validationError,
isError
} = useQuery<InvitationValidation>({
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<AcceptInviteResponse>(`/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<string, string> = {};
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<string, string> = {};
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<HTMLInputElement>) => {
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 (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md text-center">
<Loading size="lg" text={t('acceptInvitation.validating')} />
</div>
</div>
);
}
// Error state - invalid or expired token
if (isError || !invitation?.valid) {
const errorMessage = (validationError as any)?.response?.data?.error || t('acceptInvitation.invalidOrExpired');
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
<Card padding="lg">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 flex items-center justify-center">
<XCircle className="w-8 h-8 text-red-600" />
</div>
<h1 className="text-2xl font-bold text-neutral-900 mb-2">
{t('acceptInvitation.invalidTitle')}
</h1>
<p className="text-neutral-600 mb-6">
{errorMessage}
</p>
<p className="text-sm text-neutral-500 mb-6">
{t('acceptInvitation.contactAdmin')}
</p>
<Button
variant="primary"
onClick={() => navigate('/admin/login')}
>
{t('acceptInvitation.goToLogin')}
</Button>
</div>
</Card>
</div>
</div>
);
}
// Success state - account created
if (acceptMutation.isSuccess) {
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
<Card padding="lg">
<div className="text-center">
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h1 className="text-2xl font-bold text-neutral-900 mb-2">
{t('acceptInvitation.successTitle')}
</h1>
<p className="text-neutral-600 mb-6">
{t('acceptInvitation.successMessage')}
</p>
<p className="text-sm text-neutral-500 mb-6">
{t('acceptInvitation.redirecting', { seconds: redirectCountdown })}
</p>
<Button
variant="primary"
onClick={() => navigate('/admin/login')}
>
{t('acceptInvitation.goToLoginNow')}
</Button>
</div>
</Card>
</div>
</div>
);
}
// Form state - valid invitation
return (
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<div
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
style={{ backgroundColor: '#eee6d2' }}
>
<img
src="/picpeak-logo-transparent.png"
alt="PicPeak"
className="w-[180px] h-[130px] object-contain"
/>
</div>
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>
{t('acceptInvitation.title')}
</h1>
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{t('acceptInvitation.subtitle')}
</p>
</div>
{/* Invitation Info Card */}
<Card padding="md" className="mb-6">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
<Mail className="w-5 h-5 text-primary-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-500">{t('acceptInvitation.invitedAs')}</p>
<p className="font-medium text-neutral-900 truncate">{invitation.email}</p>
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<Shield className="w-3 h-3" />
{formatRole(invitation.role)}
</span>
</div>
<p className="text-xs text-neutral-500 mt-2">
{t('acceptInvitation.expiresAt', { date: formatExpirationDate(invitation.expiresAt) })}
</p>
</div>
</div>
</Card>
{/* Registration Form */}
<Card padding="lg">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Form Error */}
{errors.form && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800">{errors.form}</p>
</div>
)}
{/* Username Field */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-neutral-700 mb-1">
{t('acceptInvitation.usernameLabel')}
</label>
<Input
id="username"
type="text"
value={formData.username}
onChange={handleInputChange('username')}
error={errors.username}
placeholder={t('acceptInvitation.usernamePlaceholder')}
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
autoComplete="username"
autoFocus
/>
<p className="mt-1 text-xs text-neutral-500">
{t('acceptInvitation.usernameHelp')}
</p>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
{t('acceptInvitation.passwordLabel')}
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
placeholder={t('acceptInvitation.passwordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
tabIndex={-1}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{/* Password Strength Indicator */}
{formData.password && (
<div className="mt-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs text-neutral-500">{t('acceptInvitation.passwordStrength')}</span>
<span className={`text-xs font-medium ${
passwordStrength.level <= 1 ? 'text-red-600' :
passwordStrength.level === 2 ? 'text-yellow-600' :
passwordStrength.level === 3 ? 'text-blue-600' :
'text-green-600'
}`}>
{passwordStrength.label}
</span>
</div>
<div className="h-1.5 bg-neutral-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${passwordStrength.color}`}
style={{ width: `${(passwordStrength.level / 4) * 100}%` }}
/>
</div>
</div>
)}
{/* Password Requirements */}
<div className="mt-3 space-y-1.5">
<p className="text-xs font-medium text-neutral-600">{t('acceptInvitation.requirementsTitle')}</p>
{passwordRequirements.map((req, index) => {
const isMet = req.test(formData.password);
return (
<div key={index} className="flex items-center gap-2">
{isMet ? (
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
) : (
<div className="w-3.5 h-3.5 rounded-full border border-neutral-300" />
)}
<span className={`text-xs ${isMet ? 'text-green-700' : 'text-neutral-500'}`}>
{req.label}
</span>
</div>
);
})}
</div>
</div>
{/* Confirm Password Field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('acceptInvitation.confirmPasswordLabel')}
</label>
<div className="relative">
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder={t('acceptInvitation.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
tabIndex={-1}
>
{showConfirmPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
{formData.confirmPassword && formData.password === formData.confirmPassword && (
<div className="flex items-center gap-1.5 mt-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
<span className="text-xs text-green-700">{t('acceptInvitation.passwordsMatch')}</span>
</div>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
size="lg"
isLoading={acceptMutation.isPending}
className="w-full"
>
{t('acceptInvitation.createAccount')}
</Button>
</form>
</Card>
{/* Footer */}
<div className="text-center mt-8">
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
{t('acceptInvitation.alreadyHaveAccount')}{' '}
<a
href="/admin/login"
className="hover:underline"
style={{ color: 'var(--color-primary, #5C8762)' }}
>
{t('acceptInvitation.signIn')}
</a>
</p>
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
{t('adminLogin.poweredBy')}
</p>
</div>
</div>
</div>
);
};
AcceptInvitePage.displayName = 'AcceptInvitePage';
+1
View File
@@ -9,3 +9,4 @@ export { settingsService } from './settings.service';
export { cmsService } from './cms.service'; export { cmsService } from './cms.service';
export { notificationsService } from './notifications.service'; export { notificationsService } from './notifications.service';
export { feedbackService } from './feedback.service'; export { feedbackService } from './feedback.service';
export { userManagementService } from './userManagement.service';
+3 -2
View File
@@ -66,8 +66,9 @@ class PhotosService {
await api.post(`/admin/events/${eventId}/photos/bulk-delete`, { photoIds }); await api.post(`/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
} }
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> { async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | string | null): Promise<AdminPhoto> {
await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId }); 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<void> { async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
@@ -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<AdminUser[]> {
const response = await api.get<GetUsersResponse>('/admin/users');
return response.data.users.map(transformUser);
},
/**
* Get a single admin user by ID
*/
async getUser(id: number): Promise<AdminUser> {
const response = await api.get<GetUserResponse>(`/admin/users/${id}`);
return transformUser(response.data.user);
},
/**
* Get all available roles
*/
async getRoles(): Promise<AdminRole[]> {
const response = await api.get<GetRolesResponse>('/admin/users/roles');
return response.data.roles.map(transformRole);
},
/**
* Get all pending invitations
*/
async getInvitations(): Promise<AdminInvitation[]> {
const response = await api.get<GetInvitationsResponse>('/admin/users/invitations');
return response.data.invitations;
},
/**
* Create a new invitation
*/
async createInvitation(data: CreateInvitationData): Promise<AdminInvitation> {
const response = await api.post<CreateInvitationResponse>('/admin/users/invite', data);
return response.data.invitation;
},
/**
* Cancel a pending invitation
*/
async cancelInvitation(id: number): Promise<void> {
await api.delete(`/admin/users/invitations/${id}`);
},
/**
* Update an admin user
*/
async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> {
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, data);
return transformUser(response.data.user);
},
/**
* Deactivate an admin user
*/
async deactivateUser(id: number): Promise<string> {
const response = await api.post<DeactivateUserResponse>(`/admin/users/${id}/deactivate`);
return response.data.message;
},
/**
* Reset an admin user's password
*/
async resetPassword(id: number): Promise<ResetPasswordResponse> {
const response = await api.post<ResetPasswordResponse>(`/admin/users/${id}/reset-password`);
return response.data;
},
/**
* Validate an invitation token (public endpoint)
*/
async validateInvitation(token: string): Promise<ValidateInvitationResponse> {
const response = await api.get<ValidateInvitationResponse>(`/invite/${token}`);
return response.data;
},
/**
* Accept an invitation and create account (public endpoint)
*/
async acceptInvitation(token: string, data: AcceptInvitationData): Promise<AcceptInvitationResponse> {
const response = await api.post<AcceptInvitationResponse>(`/invite/${token}`, data);
return response.data;
},
};
+39
View File
@@ -144,6 +144,18 @@ export interface AdminUser {
username: string; username: string;
email: string; email: string;
mustChangePassword?: boolean; 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 { 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 protection types
export * from './protection'; export * from './protection';