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
+6 -4
View File
@@ -12,7 +12,8 @@ LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
RUN npm install -g npm@latest
# Pin to npm 10.x which supports --omit=dev flag
RUN npm install -g npm@10
WORKDIR /app
@@ -34,7 +35,8 @@ WORKDIR /app
RUN apk upgrade --no-cache
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
RUN npm install -g npm@latest
# Pin to npm 10.x which supports --omit=dev flag
RUN npm install -g npm@10
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
@@ -46,8 +48,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .
# Make wait script executable
RUN chmod +x wait-for-db.sh
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
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-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
@@ -258,33 +259,33 @@
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.962.0.tgz",
"integrity": "sha512-I2/1McBZCcM3PfM4ck8D6gnZR3K7+yl1fGkwTq/3ThEn9tdLjNwcdgTbPfxfX6LoecLrH9Ekoo+D9nmQ0T261w==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.964.0.tgz",
"integrity": "sha512-mDK+3qpfHnEPXeF6D8nQkJOkOvchllQosgfxv0FK9PNBuU9WVkP8yj7y3YwH6JYTgy1ejz1Ju/YfoUbbE6m7zw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/credential-provider-node": "3.962.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/credential-provider-node": "3.964.0",
"@aws-sdk/middleware-bucket-endpoint": "3.957.0",
"@aws-sdk/middleware-expect-continue": "3.957.0",
"@aws-sdk/middleware-flexible-checksums": "3.957.0",
"@aws-sdk/middleware-flexible-checksums": "3.964.0",
"@aws-sdk/middleware-host-header": "3.957.0",
"@aws-sdk/middleware-location-constraint": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "3.957.0",
"@aws-sdk/middleware-sdk-s3": "3.957.0",
"@aws-sdk/middleware-sdk-s3": "3.964.0",
"@aws-sdk/middleware-ssec": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/region-config-resolver": "3.957.0",
"@aws-sdk/signature-v4-multi-region": "3.957.0",
"@aws-sdk/signature-v4-multi-region": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.964.0",
"@smithy/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0",
"@smithy/eventstream-serde-browser": "^4.2.7",
@@ -325,23 +326,23 @@
}
},
"node_modules/@aws-sdk/client-sso": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz",
"integrity": "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.964.0.tgz",
"integrity": "sha512-IenVyY8Io2CwBgmS22xk/H5LibmSbvLnPA9oFqLORO6Ji1Ks8z/ow+ud/ZurVjFekz3LD/uxVFX3ZKGo6N7Byw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/middleware-host-header": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/region-config-resolver": "3.957.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.964.0",
"@smithy/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0",
"@smithy/fetch-http-handler": "^5.3.8",
@@ -374,9 +375,9 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.957.0.tgz",
"integrity": "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.964.0.tgz",
"integrity": "sha512-1gIfbt0KRxI8am1UYFcIxQ5QKb22JyN3k52sxyrKXJYC8Knn/rTUAZbYti45CfETe5PLadInGvWqClwGRlZKNg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "3.957.0",
@@ -411,12 +412,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.957.0.tgz",
"integrity": "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.964.0.tgz",
"integrity": "sha512-jWNSXOOBMYuxzI2rXi8x91YL07dhomyGzzh0CdaLej0LRmknmDrZcZNkVpa7Fredy1PFcmOlokwCS5PmZMN8ZQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/types": "^4.11.0",
@@ -427,12 +428,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.957.0.tgz",
"integrity": "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.964.0.tgz",
"integrity": "sha512-up7dl6vcaoXuYSwGXDvx8RnF8Lwj3jGChhyUR7krZOXLarIfUUN3ILOZnVNK5s/HnVNkEILlkdPvjhr9LVC1/Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/fetch-http-handler": "^5.3.8",
"@smithy/node-http-handler": "^4.4.7",
@@ -448,19 +449,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.962.0.tgz",
"integrity": "sha512-h0kVnXLW2d3nxbcrR/Pfg3W/+YoCguasWz7/3nYzVqmdKarGrpJzaFdoZtLgvDSZ8VgWUC4lWOTcsDMV0UNqUQ==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.964.0.tgz",
"integrity": "sha512-t4FN9qTWU4nXDU6EQ6jopvyhXw0dbQ3n+3g6x5hmc1ECFAqA+xmFd1i5LljdZCi79cUXHduQWwvW8RJHMf0qJw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/credential-provider-env": "3.957.0",
"@aws-sdk/credential-provider-http": "3.957.0",
"@aws-sdk/credential-provider-login": "3.962.0",
"@aws-sdk/credential-provider-process": "3.957.0",
"@aws-sdk/credential-provider-sso": "3.958.0",
"@aws-sdk/credential-provider-web-identity": "3.958.0",
"@aws-sdk/nested-clients": "3.958.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/credential-provider-env": "3.964.0",
"@aws-sdk/credential-provider-http": "3.964.0",
"@aws-sdk/credential-provider-login": "3.964.0",
"@aws-sdk/credential-provider-process": "3.964.0",
"@aws-sdk/credential-provider-sso": "3.964.0",
"@aws-sdk/credential-provider-web-identity": "3.964.0",
"@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/credential-provider-imds": "^4.2.7",
"@smithy/property-provider": "^4.2.7",
@@ -473,13 +474,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.962.0.tgz",
"integrity": "sha512-kHYH6Av2UifG3mPkpPUNRh/PuX6adaAcpmsclJdHdxlixMCRdh8GNeEihq480DC0GmfqdpoSf1w2CLmLLPIS6w==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.964.0.tgz",
"integrity": "sha512-c64dmTizMkJXDRzN3NYPTmUpKxegr5lmLOYPeQ60Zcbft6HFwPme8Gwy8pNxO4gG1fw6Ja2Vu6fZuSTn8aDFOQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/nested-clients": "3.958.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/protocol-http": "^5.3.7",
@@ -492,17 +493,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.962.0.tgz",
"integrity": "sha512-CS78NsWRxLa+nWqeWBEYMZTLacMFIXs1C5WJuM9kD05LLiWL32ksljoPsvNN24Bc7rCSQIIMx/U3KGvkDVZMVg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.964.0.tgz",
"integrity": "sha512-FHxDXPOj888/qc/X8s0x4aUBdp4Y3k9VePRehUJBWRhhTsAyuIJis5V0iQeY1qvtqHXYa2qd1EZHGJ3bTjHxSw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "3.957.0",
"@aws-sdk/credential-provider-http": "3.957.0",
"@aws-sdk/credential-provider-ini": "3.962.0",
"@aws-sdk/credential-provider-process": "3.957.0",
"@aws-sdk/credential-provider-sso": "3.958.0",
"@aws-sdk/credential-provider-web-identity": "3.958.0",
"@aws-sdk/credential-provider-env": "3.964.0",
"@aws-sdk/credential-provider-http": "3.964.0",
"@aws-sdk/credential-provider-ini": "3.964.0",
"@aws-sdk/credential-provider-process": "3.964.0",
"@aws-sdk/credential-provider-sso": "3.964.0",
"@aws-sdk/credential-provider-web-identity": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/credential-provider-imds": "^4.2.7",
"@smithy/property-provider": "^4.2.7",
@@ -515,12 +516,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.957.0.tgz",
"integrity": "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.964.0.tgz",
"integrity": "sha512-HaTLKqj3jeZY88E/iBjsNJsXgmRTTT7TghqeRiF8FKb/7UY1xEvasBO0c1xqfOye8dsyt35nTfTTyIsd/CBfww==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2",
@@ -532,14 +533,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.958.0.tgz",
"integrity": "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.964.0.tgz",
"integrity": "sha512-oR78TjSpjVf1IpPWQnGHEGqlnQs+K4f5nCxLK2P6JDPprXay6oknsoSiU4x2urav6VCyMPMC9KTCGjBoFKUIxQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/client-sso": "3.958.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/token-providers": "3.958.0",
"@aws-sdk/client-sso": "3.964.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/token-providers": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2",
@@ -551,13 +552,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.958.0.tgz",
"integrity": "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.964.0.tgz",
"integrity": "sha512-07JQDmbjZjOt3nL/j1wTcvQqjmPkynQYftUV/ooZ+qTbmJXFbCBdal1VCElyeiu0AgBq9dfhw0rBBcbND1ZMlA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/nested-clients": "3.958.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2",
@@ -569,9 +570,9 @@
}
},
"node_modules/@aws-sdk/lib-storage": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.962.0.tgz",
"integrity": "sha512-Ai5gWRQkzsUMQ6NPoZZoiLXoQ6/yPRcR4oracIVjyWcu48TfBpsRgbqY/5zNOM55ag1wPX9TtJJGOhK3TNk45g==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.964.0.tgz",
"integrity": "sha512-ro6B04Q5TjPgIKdSWGJ+tj2ordVF1IfZJERwGpYkrwhboNEoXBXuzpfnh2LYBPvMmFJQ+8UXSFw1jkLLgxM+ig==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/abort-controller": "^4.2.7",
@@ -586,7 +587,7 @@
"node": ">=18.0.0"
},
"peerDependencies": {
"@aws-sdk/client-s3": "^3.962.0"
"@aws-sdk/client-s3": "^3.964.0"
}
},
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
@@ -623,15 +624,15 @@
}
},
"node_modules/@aws-sdk/middleware-flexible-checksums": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.957.0.tgz",
"integrity": "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.964.0.tgz",
"integrity": "sha512-IA2kSKkwC/HHFF75nTR7s/nWt5CboB6vMgpLpvx40Cc01cMp+06Jr7U2/+DPPc8fkCagTytchY4gX9Hzn5ej8g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@aws-crypto/crc32c": "5.2.0",
"@aws-crypto/util": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/crc64-nvme": "3.957.0",
"@aws-sdk/types": "3.957.0",
"@smithy/is-array-buffer": "^4.2.0",
@@ -707,12 +708,12 @@
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.957.0.tgz",
"integrity": "sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.964.0.tgz",
"integrity": "sha512-SeFcLo3tUdI3amzoIiArd9O0i7vAB0n5fgbQHBu137s3SbSLO5tPspE25rrUITwlc5HTbHMK6UzBq+3hITmImA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-arn-parser": "3.957.0",
"@smithy/core": "^3.20.0",
@@ -746,12 +747,12 @@
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.957.0.tgz",
"integrity": "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.964.0.tgz",
"integrity": "sha512-/QyBl8WLNtqw3ucyAggumQXVCi8GRxaDGE1ElyYMmacfiwHl37S9y8JVW/QLL1lIEXGcsrhMUKV3pyFJFALA7w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@smithy/core": "^3.20.0",
@@ -764,23 +765,23 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.958.0.tgz",
"integrity": "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.964.0.tgz",
"integrity": "sha512-ql+ftRwjyZkZeG3qbrRJFVmNR0id83WEUqhFVjvrQMWspNApBhz0Ar4YVSn7Uv0QaKkaR7ALPtmdMzFr3/E4bQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/middleware-host-header": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/region-config-resolver": "3.957.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.964.0",
"@smithy/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0",
"@smithy/fetch-http-handler": "^5.3.8",
@@ -829,12 +830,12 @@
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.962.0.tgz",
"integrity": "sha512-tyxsGfLY4NSohLrJsFGXbE3j8jguWK+hdGaUQSD1gJPvmC0B82qOyJ7WBIJLWgTabU3fiF/I9EGXjzR2rKr8jQ==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.964.0.tgz",
"integrity": "sha512-gKKdIZGYV8Ohm3X8j3y6Xr2ua1oD/Wsa3N7hYro3HqcnuGvl1h+mdw0IqUU+5yEzcoM5ItLJnH+6Q8Xz+Wv9gw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/signature-v4-multi-region": "3.957.0",
"@aws-sdk/signature-v4-multi-region": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-format-url": "3.957.0",
"@smithy/middleware-endpoint": "^4.4.1",
@@ -848,12 +849,12 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.957.0.tgz",
"integrity": "sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.964.0.tgz",
"integrity": "sha512-ASQmO9EB2ukSTGpO7B2ZceSbNVivCLqWh89o/JJtcIdGpOu8p9XHpeK3hiUz2OQo2Igw03/n8s+DNvP+N9krpw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-sdk-s3": "3.957.0",
"@aws-sdk/middleware-sdk-s3": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/protocol-http": "^5.3.7",
"@smithy/signature-v4": "^5.3.7",
@@ -865,13 +866,13 @@
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.958.0.tgz",
"integrity": "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.964.0.tgz",
"integrity": "sha512-UqouLQbYepZnMFJGB/DVpA5GhF9uT98vNWSMz9PVbhgEPUKa73FECRT6YFZvZOh8kA+0JiENrnmS6d93I70ykQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/nested-clients": "3.958.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2",
@@ -963,12 +964,12 @@
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.957.0.tgz",
"integrity": "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.964.0.tgz",
"integrity": "sha512-jgob8Z/bZIh1dwEgLqE12q+aCf0ieLy7anT8bWpqMijMJqsnrPBToa7smSykfom9YHrdOgrQhXswMpE75dzLRw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/node-config-provider": "^4.3.7",
"@smithy/types": "^4.11.0",
@@ -5399,30 +5400,6 @@
"node": ">= 0.8"
}
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -6883,12 +6860,12 @@
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
"node": ">= 10"
}
},
"node_modules/is-arrayish": {
@@ -9661,6 +9638,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-addr/node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+1
View File
@@ -34,6 +34,7 @@
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
+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/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/invite', require('./src/routes/acceptInvite'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
+37 -10
View File
@@ -57,32 +57,59 @@ async function adminAuth(req, res, next) {
});
}
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
// Check if admin still exists and is active, including role info
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
let admin;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.password_changed_at',
'roles.id as role_id',
'roles.name as role_name'
)
.first();
} catch (joinError) {
// Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.first();
if (admin) {
admin.role_id = null;
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
}
}
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued
if (admin.password_changed_at) {
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
if (decoded.iat < passwordChangedTime) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
}
// Add user info to request
// Add user info to request (enhanced with role)
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email
email: admin.email,
roleId: admin.role_id,
roleName: admin.role_name
};
req.token = token; // Store token for potential revocation
+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 { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
const router = express.Router();
// Get all archived events
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
@@ -81,7 +82,7 @@ router.get('/', adminAuth, async (req, res) => {
});
// Get single archive details
router.get('/:id', adminAuth, async (req, res) => {
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -137,7 +138,7 @@ router.get('/:id', adminAuth, async (req, res) => {
});
// Restore archive
router.post('/:id/restore', adminAuth, async (req, res) => {
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -300,7 +301,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
});
// Download archive
router.get('/:id/download', adminAuth, async (req, res) => {
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -349,7 +350,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
});
// Delete archive permanently
router.delete('/:id', adminAuth, async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
+22 -21
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const fs = require('fs').promises;
@@ -12,7 +13,7 @@ const S3StorageAdapter = require('../services/storage/s3Storage');
const router = express.Router();
// Get backup configuration
router.get('/config', adminAuth, async (req, res) => {
router.get('/config', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
@@ -35,7 +36,7 @@ router.get('/config', adminAuth, async (req, res) => {
});
// Update backup configuration
router.put('/config', adminAuth, async (req, res) => {
router.put('/config', adminAuth, requirePermission('backup.create'), async (req, res) => {
try {
const updates = req.body;
@@ -97,7 +98,7 @@ router.put('/config', adminAuth, async (req, res) => {
});
// Get backup status and history
router.get('/status', adminAuth, async (req, res) => {
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const status = await getBackupStatus(limit);
@@ -110,7 +111,7 @@ router.get('/status', adminAuth, async (req, res) => {
});
// Trigger manual backup
router.post('/run', adminAuth, async (req, res) => {
router.post('/run', adminAuth, requirePermission('backup.create'), async (req, res) => {
try {
// Check if backup is already running
const status = await getBackupStatus();
@@ -131,7 +132,7 @@ router.post('/run', adminAuth, async (req, res) => {
});
// Get backup run details
router.get('/runs/:id', adminAuth, async (req, res) => {
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { id } = req.params;
@@ -160,7 +161,7 @@ router.get('/runs/:id', adminAuth, async (req, res) => {
});
// Get file states (for debugging/monitoring)
router.get('/files', adminAuth, async (req, res) => {
router.get('/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { page = 1, limit = 50, search = '' } = req.query;
const offset = (page - 1) * limit;
@@ -195,7 +196,7 @@ router.get('/files', adminAuth, async (req, res) => {
});
// Clean up old backup runs
router.delete('/cleanup', adminAuth, async (req, res) => {
router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
try {
const { days = 30 } = req.body;
@@ -209,7 +210,7 @@ router.delete('/cleanup', adminAuth, async (req, res) => {
});
// Test backup destination connectivity
router.post('/test-connection', adminAuth, async (req, res) => {
router.post('/test-connection', adminAuth, requirePermission('backup.create'), async (req, res) => {
try {
const { destination_type, ...config } = req.body;
@@ -334,7 +335,7 @@ router.post('/test-connection', adminAuth, async (req, res) => {
});
// Get backup manifest for a specific backup run
router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupRunId } = req.params;
const result = await getBackupManifest(backupRunId);
@@ -351,7 +352,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
});
// Validate a backup manifest
router.post('/manifest/validate', adminAuth, async (req, res) => {
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath } = req.body;
@@ -373,7 +374,7 @@ router.post('/manifest/validate', adminAuth, async (req, res) => {
});
// Download backup manifest
router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
router.get('/manifest/:backupRunId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupRunId } = req.params;
const { format = 'json' } = req.query;
@@ -404,7 +405,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
});
// Get manifest for specific backup
router.get('/manifests/:backupId', adminAuth, async (req, res) => {
router.get('/manifests/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupId } = req.params;
const result = await getBackupManifest(backupId);
@@ -421,7 +422,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
});
// Download manifest file
router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupId } = req.params;
const { format = 'json' } = req.query;
@@ -452,7 +453,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
});
// Validate a manifest
router.post('/manifests/validate', adminAuth, async (req, res) => {
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath, manifestData } = req.body;
@@ -481,7 +482,7 @@ router.post('/manifests/validate', adminAuth, async (req, res) => {
});
// List S3 buckets
router.get('/s3/buckets', adminAuth, async (req, res) => {
router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const config = await getBackupConfig();
@@ -513,7 +514,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
});
// List files in S3 backup location
router.get('/s3/files', adminAuth, async (req, res) => {
router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
const config = await getBackupConfig();
@@ -550,7 +551,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
});
// Clean up old S3 backups
router.delete('/s3/cleanup', adminAuth, async (req, res) => {
router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
try {
const { retentionDays = 30, dryRun = false } = req.body;
const config = await getBackupConfig();
@@ -612,7 +613,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
});
// Test S3 upload functionality
router.post('/s3/test-upload', adminAuth, async (req, res) => {
router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), async (req, res) => {
try {
const config = await getBackupConfig();
@@ -664,7 +665,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
});
// Download entire backup
router.get('/download/:backupId', adminAuth, async (req, res) => {
router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupId } = req.params;
@@ -752,7 +753,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
});
// Get current file checksums
router.get('/checksums', adminAuth, async (req, res) => {
router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { path: targetPath = '', recursive = true } = req.query;
const checksums = {};
@@ -821,7 +822,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
});
// Estimate backup size before running
router.post('/estimate', adminAuth, async (req, res) => {
router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { includeArchived = true } = req.body;
+4 -3
View File
@@ -2,10 +2,11 @@ const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
// Get all CMS pages
router.get('/pages', adminAuth, async (req, res) => {
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
try {
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
res.json(pages);
@@ -16,7 +17,7 @@ router.get('/pages', adminAuth, async (req, res) => {
});
// Get a single CMS page
router.get('/pages/:slug', adminAuth, async (req, res) => {
router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, res) => {
try {
const { slug } = req.params;
const page = await db('cms_pages').where('slug', slug).first();
@@ -33,7 +34,7 @@ router.get('/pages/:slug', adminAuth, async (req, res) => {
});
// Update a CMS page
router.put('/pages/:slug', adminAuth, [
router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('title_en').optional().isString(),
body('title_de').optional().isString(),
body('content_en').optional().isString(),
+6 -5
View File
@@ -3,10 +3,11 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
// Get all global categories
router.get('/global', adminAuth, async (req, res) => {
router.get('/global', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
@@ -20,7 +21,7 @@ router.get('/global', adminAuth, async (req, res) => {
});
// Get categories for a specific event (global + event-specific)
router.get('/event/:eventId', adminAuth, async (req, res) => {
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { eventId } = req.params;
@@ -40,7 +41,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
});
// Create a new category
router.post('/', adminAuth, [
router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required'),
body('slug').optional(),
body('is_global').optional().isBoolean(),
@@ -104,7 +105,7 @@ router.post('/', adminAuth, [
});
// Update a category
router.put('/:id', adminAuth, [
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required')
], async (req, res) => {
try {
@@ -149,7 +150,7 @@ router.put('/:id', adminAuth, [
});
// Delete a category
router.delete('/:id', adminAuth, async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { id } = req.params;
+6 -5
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const { body, param, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
@@ -15,7 +16,7 @@ const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_temp
* GET /admin/css-templates
* Get all CSS templates
*/
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('branding.view'), async (req, res) => {
try {
const templates = await withRetry(() =>
db('css_templates').orderBy('slot_number')
@@ -31,7 +32,7 @@ router.get('/', adminAuth, async (req, res) => {
* GET /admin/css-templates/enabled
* Get only enabled templates (for event form dropdown)
*/
router.get('/enabled', adminAuth, async (req, res) => {
router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req, res) => {
try {
const templates = await withRetry(() =>
db('css_templates')
@@ -50,7 +51,7 @@ router.get('/enabled', adminAuth, async (req, res) => {
* GET /admin/css-templates/:slotNumber
* Get a specific template by slot number
*/
router.get('/:slotNumber', adminAuth, [
router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
param('slotNumber').isInt({ min: 1, max: 3 })
], async (req, res) => {
try {
@@ -81,7 +82,7 @@ router.get('/:slotNumber', adminAuth, [
* PUT /admin/css-templates/:slotNumber
* Update a template
*/
router.put('/:slotNumber', adminAuth, [
router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
param('slotNumber').isInt({ min: 1, max: 3 }),
body('name').optional().isString().isLength({ max: 50 }),
body('css_content').optional().isString(),
@@ -158,7 +159,7 @@ router.put('/:slotNumber', adminAuth, [
* POST /admin/css-templates/:slotNumber/reset
* Reset template to default (only for slot 1)
*/
router.post('/:slotNumber/reset', adminAuth, [
router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'), [
param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
], async (req, res) => {
try {
+5 -4
View File
@@ -1,12 +1,13 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const router = express.Router();
// Get dashboard statistics
router.get('/stats', adminAuth, async (req, res) => {
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
// Get active events count
const activeEvents = await db('events')
@@ -106,7 +107,7 @@ router.get('/stats', adminAuth, async (req, res) => {
});
// Get recent activity
router.get('/activity', adminAuth, async (req, res) => {
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
@@ -144,7 +145,7 @@ router.get('/activity', adminAuth, async (req, res) => {
});
// Get system health status
router.get('/health', adminAuth, async (req, res) => {
router.get('/health', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const os = require('os');
@@ -216,7 +217,7 @@ router.get('/health', adminAuth, async (req, res) => {
});
// Get analytics data for charts
router.get('/analytics', adminAuth, async (req, res) => {
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const days = sanitizeDays(req.query.days || 7);
+9 -8
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { databaseBackupService } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
@@ -11,7 +12,7 @@ router.use(adminAuth);
/**
* Get database backup status and configuration
*/
router.get('/status', async (req, res) => {
router.get('/status', requirePermission('backup.view'), async (req, res) => {
try {
// Get configuration
const config = await databaseBackupService.getBackupConfig();
@@ -45,7 +46,7 @@ router.get('/status', async (req, res) => {
/**
* Update database backup configuration
*/
router.put('/config', async (req, res) => {
router.put('/config', requirePermission('backup.create'), async (req, res) => {
try {
const allowedSettings = [
'database_backup_enabled',
@@ -108,7 +109,7 @@ router.put('/config', async (req, res) => {
/**
* Trigger manual database backup
*/
router.post('/backup', async (req, res) => {
router.post('/backup', requirePermission('backup.create'), async (req, res) => {
try {
if (databaseBackupService.isRunning) {
return res.status(409).json({ error: 'Backup already in progress' });
@@ -134,7 +135,7 @@ router.post('/backup', async (req, res) => {
/**
* Get current backup progress
*/
router.get('/progress', async (req, res) => {
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
try {
const progress = databaseBackupService.getProgress();
@@ -151,7 +152,7 @@ router.get('/progress', async (req, res) => {
/**
* Get backup history with pagination
*/
router.get('/history', async (req, res) => {
router.get('/history', requirePermission('backup.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
@@ -183,7 +184,7 @@ router.get('/history', async (req, res) => {
/**
* Delete old backup files
*/
router.delete('/cleanup', async (req, res) => {
router.delete('/cleanup', requirePermission('backup.delete'), async (req, res) => {
try {
const { retentionDays = 30 } = req.body;
@@ -202,7 +203,7 @@ router.delete('/cleanup', async (req, res) => {
/**
* Test database backup configuration
*/
router.post('/test', async (req, res) => {
router.post('/test', requirePermission('backup.create'), async (req, res) => {
try {
const config = await databaseBackupService.getBackupConfig();
@@ -255,7 +256,7 @@ router.post('/test', async (req, res) => {
/**
* Get table checksums
*/
router.get('/checksums', async (req, res) => {
router.get('/checksums', requirePermission('backup.view'), async (req, res) => {
try {
const checksums = await databaseBackupService.getTableChecksums();
+8 -5
View File
@@ -3,10 +3,11 @@ const nodemailer = require('nodemailer');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
// Get email configuration
router.get('/config', adminAuth, async (req, res) => {
router.get('/config', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const config = await db('email_configs').first();
@@ -37,6 +38,7 @@ router.get('/config', adminAuth, async (req, res) => {
// Update email configuration
router.post('/config', [
adminAuth,
requirePermission('email.edit'),
body('smtp_host').notEmpty().withMessage('SMTP host is required'),
body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'),
body('from_email').isEmail().withMessage('Invalid from email address')
@@ -100,7 +102,7 @@ router.post('/config', [
});
// Test email configuration
router.post('/test', adminAuth, async (req, res) => {
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
try {
const { test_email } = req.body;
@@ -236,7 +238,7 @@ router.post('/test', adminAuth, async (req, res) => {
});
// Get email templates
router.get('/templates', adminAuth, async (req, res) => {
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const templates = await db('email_templates')
.select('*')
@@ -290,7 +292,7 @@ router.get('/templates', adminAuth, async (req, res) => {
});
// Get single template
router.get('/templates/:key', adminAuth, async (req, res) => {
router.get('/templates/:key', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const template = await db('email_templates')
.where('template_key', req.params.key)
@@ -346,6 +348,7 @@ router.get('/templates/:key', adminAuth, async (req, res) => {
// Update email template
router.put('/templates/:key', [
adminAuth,
requirePermission('email.edit'),
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
@@ -424,7 +427,7 @@ router.put('/templates/:key', [
});
// Preview email template
router.post('/templates/:key/preview', adminAuth, async (req, res) => {
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const template = await db('email_templates')
.where('template_key', req.params.key)
+3 -2
View File
@@ -6,6 +6,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const eventRenameService = require('../services/eventRenameService');
const router = express.Router();
@@ -13,7 +14,7 @@ const router = express.Router();
* POST /api/admin/events/:eventId/rename
* Rename an event
*/
router.post('/:eventId/rename', adminAuth, [
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
@@ -58,7 +59,7 @@ router.post('/:eventId/rename', adminAuth, [
* POST /api/admin/events/:eventId/validate-rename
* Validate a potential rename without executing it
*/
router.post('/:eventId/validate-rename', adminAuth, [
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
+2 -1
View File
@@ -3,9 +3,10 @@
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { requirePermission } = require('../middleware/permissions');
// Enhanced event creation with password validation
router.post('/', adminAuth, [
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
+55 -24
View File
@@ -3,6 +3,7 @@ const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
const bcrypt = require('bcrypt');
const crypto = require('crypto');
@@ -102,7 +103,7 @@ const hasCustomerContactColumns = async () => {
};
// Create new event
router.post('/', adminAuth, [
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
@@ -296,6 +297,7 @@ router.post('/', adminAuth, [
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
@@ -375,7 +377,7 @@ router.post('/', adminAuth, [
});
// Get all events with pagination and filters
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
@@ -387,7 +389,12 @@ router.get('/', adminAuth, async (req, res) => {
// Build query
let query = db('events');
// Editor role can only see their own events
if (req.admin.roleName === 'editor') {
query = query.where('created_by', req.admin.id);
}
// Apply search filter
if (search) {
const escapedSearch = escapeLikePattern(search);
@@ -465,13 +472,18 @@ router.get('/', adminAuth, async (req, res) => {
});
// Get single event details
router.get('/:id', adminAuth, async (req, res) => {
router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => {
try {
const { id } = req.params;
const event = await db('events')
.where('id', id)
.first();
let query = db('events').where('id', id);
// Editor role can only see their own events
if (req.admin.roleName === 'editor') {
query = query.where('created_by', req.admin.id);
}
const event = await query.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
@@ -525,7 +537,7 @@ router.get('/:id', adminAuth, async (req, res) => {
});
// Update event
router.put('/:id', adminAuth, [
router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
@@ -569,7 +581,8 @@ router.put('/:id', adminAuth, [
throw new Error('Password must be at least 6 characters long');
}
return true;
})
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -659,7 +672,12 @@ router.put('/:id', adminAuth, [
});
// Check if event exists
const event = await db('events').where('id', id).first();
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
@@ -696,7 +714,7 @@ router.put('/:id', adminAuth, [
});
// Delete event
router.delete('/:id', adminAuth, async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
try {
const { id } = req.params;
@@ -777,11 +795,16 @@ router.delete('/:id', adminAuth, async (req, res) => {
});
// Toggle event status
router.post('/:id/toggle-status', adminAuth, async (req, res) => {
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
@@ -812,12 +835,17 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => {
});
// Reset event password
router.post('/:id/reset-password', adminAuth, async (req, res) => {
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
try {
const { id } = req.params;
const { sendEmail = true } = req.body;
const event = await db('events').where('id', id).first();
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
@@ -874,15 +902,18 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
});
// Resend creation email
router.post('/:id/resend-email', adminAuth, async (req, res) => {
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
try {
const { id } = req.params;
// Get event details
const event = await db('events')
.where('id', id)
.first();
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
@@ -954,7 +985,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
});
// Archive event
router.post('/:id/archive', adminAuth, async (req, res) => {
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
try {
const { id } = req.params;
@@ -985,7 +1016,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
});
// Bulk archive events
router.post('/bulk-archive', adminAuth, [
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
body('eventIds').isArray().withMessage('eventIds must be an array'),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
], async (req, res) => {
+3 -2
View File
@@ -2,6 +2,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
const router = express.Router();
// GET /api/admin/external-media/list?path=relative/dir
router.get('/list', adminAuth, async (req, res) => {
router.get('/list', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const relPath = (req.query.path || '').replace(/^\/+/, '');
const result = await list(relPath);
@@ -45,7 +46,7 @@ async function walkDir(dir, baseDir) {
// POST /api/admin/events/:id/import-external
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
router.post('/events/:id/import-external', adminAuth, async (req, res) => {
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const eventId = parseInt(req.params.id);
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
+14 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
@@ -13,8 +14,9 @@ const {
} = require('../utils/feedbackValidation');
// Get event feedback settings
router.get('/events/:eventId/feedback-settings',
router.get('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.view'),
validateEventId,
checkValidation,
async (req, res) => {
@@ -39,6 +41,7 @@ router.get('/events/:eventId/feedback-settings',
// Update event feedback settings
router.put('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.edit'),
validateEventId,
validateFeedbackSettings,
checkValidation,
@@ -75,6 +78,7 @@ router.put('/events/:eventId/feedback-settings',
// Get feedback for an event (with filters)
router.get('/events/:eventId/feedback',
adminAuth,
requirePermission('events.view'),
validateEventId,
checkValidation,
async (req, res) => {
@@ -159,6 +163,7 @@ router.get('/events/:eventId/feedback',
// Moderate feedback (approve/hide/reject)
router.put('/feedback/:feedbackId/:action',
adminAuth,
requirePermission('events.edit'),
async (req, res) => {
try {
const { feedbackId, action } = req.params;
@@ -180,6 +185,7 @@ router.put('/feedback/:feedbackId/:action',
// Delete feedback
router.delete('/feedback/:feedbackId',
adminAuth,
requirePermission('events.delete'),
async (req, res) => {
try {
const { feedbackId } = req.params;
@@ -197,6 +203,7 @@ router.delete('/feedback/:feedbackId',
// Get feedback analytics for an event
router.get('/events/:eventId/feedback-analytics',
adminAuth,
requirePermission('events.view'),
validateEventId,
checkValidation,
async (req, res) => {
@@ -296,6 +303,7 @@ router.get('/events/:eventId/feedback-analytics',
// Export feedback data
router.get('/events/:eventId/feedback/export',
adminAuth,
requirePermission('events.view'),
validateEventId,
checkValidation,
async (req, res) => {
@@ -324,6 +332,7 @@ router.get('/events/:eventId/feedback/export',
// Get pending moderation items (across all events)
router.get('/feedback/pending-moderation',
adminAuth,
requirePermission('events.view'),
async (req, res) => {
try {
const pending = await feedbackService.getPendingModeration();
@@ -338,6 +347,7 @@ router.get('/feedback/pending-moderation',
// Word filter management
router.get('/word-filters',
adminAuth,
requirePermission('settings.view'),
async (req, res) => {
try {
const filters = await feedbackModeration.getAllWordFilters();
@@ -351,6 +361,7 @@ router.get('/word-filters',
router.post('/word-filters',
adminAuth,
requirePermission('settings.edit'),
validateWordFilter,
checkValidation,
async (req, res) => {
@@ -378,6 +389,7 @@ router.post('/word-filters',
router.put('/word-filters/:id',
adminAuth,
requirePermission('settings.edit'),
async (req, res) => {
try {
const { id } = req.params;
@@ -395,6 +407,7 @@ router.put('/word-filters/:id',
router.delete('/word-filters/:id',
adminAuth,
requirePermission('settings.edit'),
async (req, res) => {
try {
const { id } = req.params;
+9 -8
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
@@ -9,7 +10,7 @@ const router = express.Router();
/**
* Get image security settings
*/
router.get('/settings', adminAuth, async (req, res) => {
router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
@@ -46,7 +47,7 @@ router.get('/settings', adminAuth, async (req, res) => {
/**
* Update image security settings
*/
router.put('/settings', adminAuth, async (req, res) => {
router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const updates = req.body;
@@ -97,7 +98,7 @@ router.put('/settings', adminAuth, async (req, res) => {
/**
* Get security monitoring dashboard data
*/
router.get('/dashboard', adminAuth, async (req, res) => {
router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { timeframe = '24h' } = req.query;
@@ -202,7 +203,7 @@ router.get('/dashboard', adminAuth, async (req, res) => {
/**
* Get detailed security logs
*/
router.get('/logs', adminAuth, async (req, res) => {
router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const {
page = 1,
@@ -271,7 +272,7 @@ router.get('/logs', adminAuth, async (req, res) => {
/**
* Get image access logs for a specific event
*/
router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
router.get('/events/:eventId/access-logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { eventId } = req.params;
const { page = 1, limit = 50 } = req.query;
@@ -321,7 +322,7 @@ router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
/**
* Block/unblock suspicious IPs
*/
router.post('/block-ip', adminAuth, async (req, res) => {
router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { ip, action = 'block' } = req.body;
@@ -367,7 +368,7 @@ router.post('/block-ip', adminAuth, async (req, res) => {
/**
* Clear security logs older than specified time
*/
router.delete('/logs/cleanup', adminAuth, async (req, res) => {
router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { olderThan = '30d' } = req.body;
@@ -424,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, async (req, res) => {
/**
* Export security data for analysis
*/
router.get('/export', adminAuth, async (req, res) => {
router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { format = 'json', timeframe = '7d' } = req.query;
+5 -4
View File
@@ -1,10 +1,11 @@
const express = require('express');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
// Get notifications (unread activity logs)
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { limit = 20, includeRead = false } = req.query;
@@ -64,7 +65,7 @@ router.get('/', adminAuth, async (req, res) => {
});
// Mark notification as read
router.put('/:id/read', adminAuth, async (req, res) => {
router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { id } = req.params;
@@ -82,7 +83,7 @@ router.put('/:id/read', adminAuth, async (req, res) => {
});
// Mark all notifications as read
router.put('/read-all', adminAuth, async (req, res) => {
router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
await db('activity_logs')
.whereNull('read_at')
@@ -98,7 +99,7 @@ router.put('/read-all', adminAuth, async (req, res) => {
});
// Delete old notifications (older than 30 days and read)
router.delete('/clear-old', adminAuth, async (req, res) => {
router.delete('/clear-old', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
// Use database-agnostic date calculation
const thirtyDaysAgo = new Date();
+5 -4
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const { body, query, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
const { PhotoExportService } = require('../services/photoExportService');
@@ -17,7 +18,7 @@ const exportService = new PhotoExportService();
* GET /admin/photos/:eventId/filtered
* Get filtered photos with pagination
*/
router.get('/:eventId/filtered', adminAuth, [
router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
query('has_likes').optional().isBoolean(),
@@ -131,7 +132,7 @@ router.get('/:eventId/filtered', adminAuth, [
* GET /admin/photos/:eventId/filter-summary
* Get just the summary counts for filter UI
*/
router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
@@ -153,7 +154,7 @@ router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
* POST /admin/photos/:eventId/export
* Export selected or filtered photos
*/
router.post('/:eventId/export', adminAuth, [
router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), [
body('photo_ids').optional().isArray(),
body('photo_ids.*').optional().isInt(),
body('filter').optional().isObject(),
@@ -213,7 +214,7 @@ router.post('/:eventId/export', adminAuth, [
* GET /admin/photos/export-formats
* Get available export format options
*/
router.get('/export-formats', adminAuth, (req, res) => {
router.get('/export-formats', adminAuth, requirePermission('photos.view'), (req, res) => {
res.json({
success: true,
data: PhotoExportService.getFormatOptions()
+46 -33
View File
@@ -4,6 +4,7 @@ const path = require('path');
const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
@@ -109,7 +110,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
// Upload photos for an event
// Max file count is configurable via general settings
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
@@ -176,21 +177,23 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Parse category_id to number if provided
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
// Determine photo type from category_id parameter (for backwards compatibility)
// Determine photo type and category name
let photoType = 'individual'; // default
let categoryName = 'individual';
if (parsedCategoryId === 1 || category_id === 'collage') {
photoType = 'collage';
categoryName = 'collages';
} else if (parsedCategoryId === 2 || category_id === 'individual') {
photoType = 'individual';
categoryName = 'individual';
}
// For backwards compatibility, accept string values
if (category_id === 'collage') {
// Look up the actual category from database if provided
if (parsedCategoryId && !isNaN(parsedCategoryId)) {
const category = await db('photo_categories').where({ id: parsedCategoryId }).first();
if (category) {
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
// Use category slug for type determination
if (category.slug === 'collage' || category.slug === 'collages') {
photoType = 'collage';
}
}
} else if (category_id === 'collage') {
// For backwards compatibility, accept string values
photoType = 'collage';
categoryName = 'collages';
}
@@ -256,6 +259,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
path: relativePath,
thumbnail_path: null, // Will generate after successful commit
type: photoType,
category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size // Use actual file size from stat
};
@@ -420,7 +424,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
});
// Delete a photo
router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -477,7 +481,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
});
// Update a photo (e.g., change category)
router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
@@ -517,7 +521,15 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
.where({ id: photoId, event_id: eventId })
.update(updateData);
res.json({ message: 'Photo updated successfully' });
// Fetch and return the updated photo
const updatedPhoto = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
res.json({
message: 'Photo updated successfully',
photo: updatedPhoto
});
} catch (error) {
console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' });
@@ -525,7 +537,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
});
// Bulk delete photos
router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds } = req.body;
@@ -593,7 +605,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
});
// Bulk update photos
router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds, updates } = req.body;
@@ -651,7 +663,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
});
// Download a photo
router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => {
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -683,14 +695,15 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
});
// Get all photos for an event
router.get('/:eventId/photos', adminAuth, async (req, res) => {
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId } = req.params;
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
let query = db('photos')
.where({ 'photos.event_id': eventId })
.select('photos.*');
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
// Filter by type (individual/collage) - category_id maps to type
if (category_id !== undefined) {
@@ -747,9 +760,9 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
// Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_slug: photo.type,
category_id: photo.category_id || photo.type,
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
category_slug: photo.pc_slug || photo.type,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Feedback data
@@ -767,7 +780,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
});
// Serve photo with admin authentication
router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -804,7 +817,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
});
// Serve thumbnail with admin authentication
router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -844,7 +857,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
});
// Debug endpoint to check photo existence
router.get('/:eventId/debug', adminAuth, async (req, res) => {
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId } = req.params;
@@ -870,7 +883,7 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
// ============================================
// Initialize a chunked upload
router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const { eventId } = req.params;
const { filename, fileSize, mimeType, totalChunks } = req.body;
@@ -908,7 +921,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
});
// Upload a chunk
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, async (req, res) => {
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const { uploadId, chunkIndex } = req.params;
@@ -929,7 +942,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, a
});
// Complete chunked upload and process the file
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req, res) => {
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const { eventId, uploadId } = req.params;
const { category_id } = req.body;
@@ -971,7 +984,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req
});
// Get upload status
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, res) => {
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { uploadId } = req.params;
@@ -989,7 +1002,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, r
});
// Abort chunked upload
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, async (req, res) => {
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
try {
const { uploadId } = req.params;
+149 -17
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { restoreService } = require('../services/restoreService');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { body, query, validationResult } = require('express-validator');
const logger = require('../utils/logger');
const { db } = require('../database/db');
@@ -16,10 +17,36 @@ const fs = require('fs').promises;
// Apply admin authentication to all routes
router.use(adminAuth);
/**
* Transform frontend S3 config to backend format
* Frontend sends: s3Endpoint, s3Bucket, s3AccessKey, s3SecretKey, s3Region
* Backend expects: endpoint, bucket, accessKeyId, secretAccessKey, region
*/
function transformS3Config(body) {
if (body.s3Config) {
// Already in correct format
return body.s3Config;
}
// Check if frontend sent flat S3 config fields
if (body.s3Endpoint || body.s3Bucket || body.s3AccessKey || body.s3SecretKey) {
return {
endpoint: body.s3Endpoint,
bucket: body.s3Bucket,
accessKeyId: body.s3AccessKey,
secretAccessKey: body.s3SecretKey,
region: body.s3Region || 'us-east-1',
forcePathStyle: body.s3ForcePathStyle !== false
};
}
return null;
}
/**
* Get restore service status and history
*/
router.get('/status', async (req, res) => {
router.get('/status', requirePermission('backup.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const history = await restoreService.getRestoreHistory(limit);
@@ -47,7 +74,7 @@ router.get('/status', async (req, res) => {
/**
* Validate restore request
*/
router.post('/validate', [
router.post('/validate', requirePermission('backup.restore'), [
body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
@@ -63,18 +90,38 @@ router.post('/validate', [
}
try {
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
// Perform dry run validation
const result = await restoreService.restore({
...req.body,
source: req.body.source,
manifestPath: req.body.manifestPath,
restoreType: req.body.restoreType,
selectedItems: req.body.selectedItems,
s3Config,
dryRun: true,
force: false
});
// Transform spaceCheck to match frontend expected format
const spaceCheck = result.spaceCheck ? {
sufficient: result.spaceCheck.hasEnoughSpace,
required: result.spaceCheck.requiredBytes,
available: result.spaceCheck.availableBytes,
requiredFormatted: result.spaceCheck.requiredFormatted,
availableFormatted: result.spaceCheck.availableFormatted,
// Keep original fields for backwards compatibility
hasEnoughSpace: result.spaceCheck.hasEnoughSpace,
requiredBytes: result.spaceCheck.requiredBytes,
availableBytes: result.spaceCheck.availableBytes
} : null;
res.json({
success: true,
data: {
validation: result.validation,
spaceCheck: result.spaceCheck,
spaceCheck,
logs: result.logs
}
});
@@ -82,7 +129,7 @@ router.post('/validate', [
logger.error('Restore validation failed:', error);
res.status(400).json({
success: false,
error: 'Restore validation failed',
error: error.message || 'Restore validation failed',
logs: restoreService.restoreLog
});
}
@@ -91,7 +138,7 @@ router.post('/validate', [
/**
* Start restore operation
*/
router.post('/start', [
router.post('/start', requirePermission('backup.restore'), [
body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
@@ -135,19 +182,28 @@ router.post('/start', [
// Log restore attempt
logger.warn('Restore operation started', {
user: req.user.email,
user: req.admin.email,
ip: req.ip,
restoreType: req.body.restoreType,
source: req.body.source
});
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
// Start restore in background
restoreService.restore({
...req.body,
source: req.body.source,
manifestPath: req.body.manifestPath,
restoreType: req.body.restoreType,
selectedItems: req.body.selectedItems,
skipPreBackup: req.body.skipPreBackup,
force: req.body.force,
s3Config,
dryRun: false,
operator: {
type: 'manual',
userId: req.user.id,
userId: req.admin.id,
ip: req.ip
}
}).catch(error => {
@@ -160,9 +216,10 @@ router.post('/start', [
});
} catch (error) {
logger.error('Failed to start restore:', error);
logger.error('Error stack:', error.stack);
res.status(500).json({
success: false,
error: 'Failed to start restore operation'
error: error.message || 'Failed to start restore operation'
});
}
});
@@ -170,7 +227,7 @@ router.post('/start', [
/**
* Get current restore progress
*/
router.get('/progress', async (req, res) => {
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
try {
const progress = restoreService.getProgress();
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
@@ -195,7 +252,7 @@ router.get('/progress', async (req, res) => {
/**
* Get restore run details
*/
router.get('/run/:id', async (req, res) => {
router.get('/run/:id', requirePermission('backup.view'), async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
@@ -250,7 +307,7 @@ router.get('/run/:id', async (req, res) => {
/**
* Get restore run report
*/
router.get('/run/:id/report', async (req, res) => {
router.get('/run/:id/report', requirePermission('backup.view'), async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
@@ -289,7 +346,7 @@ router.get('/run/:id/report', async (req, res) => {
/**
* List available backups for restore
*/
router.get('/available-backups', async (req, res) => {
router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
try {
const backups = [];
@@ -349,10 +406,85 @@ router.get('/available-backups', async (req, res) => {
}
});
/**
* List backups for restore (POST version for frontend compatibility)
* Accepts source type in request body
*/
router.post('/list-backups', requirePermission('backup.view'), async (req, res) => {
try {
const { source } = req.body; // 'local', 's3', or undefined for all
const backups = [];
// Get backup configuration
const backupConfig = await getBackupConfig();
// Get database backups from backup_runs table
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(20);
for (const run of backupRuns) {
const isS3 = run.manifest_path.startsWith('s3://');
const backupType = isS3 ? 's3' : 'local';
// Filter by source if specified
if (source && source !== backupType) {
continue;
}
backups.push({
id: run.id,
type: backupType,
name: `Backup from ${new Date(run.completed_at).toLocaleString()}`,
path: run.manifest_path,
manifest_path: run.manifest_path,
manifestId: run.manifest_id,
manifestPath: run.manifest_path,
size: parseInt(run.total_size_bytes) || 0,
total_size: parseInt(run.total_size_bytes) || 0,
total_size_bytes: parseInt(run.total_size_bytes) || 0,
filesCount: run.files_backed_up || 0,
files_backed_up: run.files_backed_up || 0,
duration: run.duration_seconds,
duration_seconds: run.duration_seconds,
// Frontend expects snake_case date fields
created_at: run.completed_at,
completed_at: run.completed_at,
started_at: run.started_at,
// camelCase aliases
completedAt: run.completed_at,
startedAt: run.started_at,
// Backup metadata
status: run.status,
backup_type: run.backup_type,
backupType: run.backup_type,
backup_mode: run.backup_mode,
backupMode: run.backup_mode,
app_version: run.app_version,
appVersion: run.app_version
});
}
res.json({
success: true,
data: backups,
source: source || 'all'
});
} catch (error) {
logger.error('Failed to list backups for restore:', error);
res.status(500).json({
success: false,
error: 'Failed to list backups for restore'
});
}
});
/**
* Get restore settings
*/
router.get('/settings', async (req, res) => {
router.get('/settings', requirePermission('backup.view'), async (req, res) => {
try {
const settings = await getRestoreSettings();
res.json({
@@ -371,7 +503,7 @@ router.get('/settings', async (req, res) => {
/**
* Update restore settings
*/
router.put('/settings', [
router.put('/settings', requirePermission('backup.restore'), [
body('restore_allow_force').optional().isBoolean(),
body('restore_require_pre_backup').optional().isBoolean(),
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
+16 -15
View File
@@ -6,6 +6,7 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const { clearSettingsCache } = require('../services/rateLimitService');
const {
@@ -92,7 +93,7 @@ const faviconUpload = multer({
});
// Get all settings
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const settings = await db('app_settings').select('*');
@@ -120,7 +121,7 @@ router.get('/', adminAuth, async (req, res) => {
});
// Get settings by type
router.get('/:type', adminAuth, async (req, res) => {
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { type } = req.params;
const settings = await db('app_settings')
@@ -151,7 +152,7 @@ router.get('/:type', adminAuth, async (req, res) => {
});
// Get password complexity settings for frontend
router.get('/password/complexity', adminAuth, async (req, res) => {
router.get('/password/complexity', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
@@ -172,7 +173,7 @@ router.get('/password/complexity', adminAuth, async (req, res) => {
});
// Update branding settings
router.put('/branding', adminAuth, async (req, res) => {
router.put('/branding', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const {
company_name,
@@ -313,7 +314,7 @@ router.put('/branding', adminAuth, async (req, res) => {
});
// Upload logo
router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No logo file uploaded' });
@@ -375,7 +376,7 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
});
// Upload watermark logo
router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => {
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
@@ -437,7 +438,7 @@ router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'
});
// Update theme settings
router.put('/theme', adminAuth, async (req, res) => {
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const themeSettings = req.body;
@@ -474,7 +475,7 @@ router.put('/theme', adminAuth, async (req, res) => {
});
// Update general settings
router.put('/general', adminAuth, async (req, res) => {
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = { ...req.body };
let uploadLimitTouched = false;
@@ -573,7 +574,7 @@ router.put('/general', adminAuth, async (req, res) => {
});
// Update security settings
router.put('/security', adminAuth, async (req, res) => {
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
@@ -612,7 +613,7 @@ router.put('/security', adminAuth, async (req, res) => {
});
// Update analytics settings
router.put('/analytics', adminAuth, async (req, res) => {
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
@@ -649,7 +650,7 @@ router.put('/analytics', adminAuth, async (req, res) => {
});
// Get storage info
router.get('/storage/info', adminAuth, async (req, res) => {
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Get total storage used
const totalStorage = await db('photos')
@@ -877,7 +878,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
});
// Upload favicon endpoint
router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => {
router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUpload.single('favicon'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No favicon file provided' });
@@ -915,7 +916,7 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
});
// Update rate limit settings
router.put('/security/rate-limit', adminAuth, [
router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit'), [
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
@@ -979,7 +980,7 @@ router.put('/security/rate-limit', adminAuth, [
});
// Get default public site template
router.get('/public-site/default', adminAuth, async (req, res) => {
router.get('/public-site/default', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const defaults = await getDefaultPublicSitePayload();
@@ -1000,7 +1001,7 @@ router.get('/public-site/default', adminAuth, async (req, res) => {
});
// Reset public site template to defaults
router.post('/public-site/reset', adminAuth, async (req, res) => {
router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const entries = [
{
+4 -3
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
const router = express.Router();
// Get system version
router.get('/version', adminAuth, async (req, res) => {
router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Read backend version from package.json
let backendVersion = '1.0.0';
@@ -35,7 +36,7 @@ router.get('/version', adminAuth, async (req, res) => {
});
// Get comprehensive system status
router.get('/status', adminAuth, async (req, res) => {
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Database size - check if PostgreSQL or SQLite
let dbSize = 0;
@@ -170,7 +171,7 @@ router.get('/status', adminAuth, async (req, res) => {
});
// Get database statistics
router.get('/database', adminAuth, async (req, res) => {
router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Get table info
const tables = [
+5 -4
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail } = require('../services/imageProcessor');
const path = require('path');
const fs = require('fs').promises;
@@ -10,7 +11,7 @@ const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Get thumbnail settings
router.get('/settings', adminAuth, async (req, res) => {
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const settings = await db('app_settings')
.whereIn('key', [
@@ -42,7 +43,7 @@ router.get('/settings', adminAuth, async (req, res) => {
});
// Update thumbnail settings
router.put('/settings', adminAuth, async (req, res) => {
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { width, height, fit, quality, format } = req.body;
@@ -91,7 +92,7 @@ router.put('/settings', adminAuth, async (req, res) => {
});
// Regenerate all thumbnails with new settings
router.post('/regenerate', adminAuth, async (req, res) => {
router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.body; // Optional: regenerate for specific event only
@@ -164,7 +165,7 @@ router.post('/regenerate', adminAuth, async (req, res) => {
});
// Get regeneration status
router.get('/regenerate/status', adminAuth, async (req, res) => {
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
// Count photos with and without thumbnails
const totalPhotos = await db('photos').count('id as count').first();
+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;
+26 -13
View File
@@ -70,52 +70,65 @@ router.post('/admin/login', [
logger.warn('Suspicious login pattern detected', { username, ipAddress });
}
// Fetch admin with role information
const admin = await db('admin_users')
.where({ username })
.orWhere({ email: username })
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.username', username)
.orWhere('admin_users.email', username)
.select(
'admin_users.*',
'roles.name as role_name',
'roles.display_name as role_display_name'
)
.first();
// Use generic error to prevent user enumeration
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
if (!admin.is_active) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
// Successful login
await trackSuccessfulLogin(username, ipAddress, userAgent);
// Update last login and login metadata
await db('admin_users').where('id', admin.id).update({
await db('admin_users').where('id', admin.id).update({
last_login: new Date(),
last_login_ip: ipAddress
});
// Generate token with additional claims
const token = jwt.sign({
// Generate token with additional claims including role
const token = jwt.sign({
id: admin.id,
username: admin.username,
type: 'admin',
role: admin.role_name, // Add role to JWT
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, token);
// Include role in response
res.json({
token,
user: {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
}
});
} catch (error) {
+50 -11
View File
@@ -728,15 +728,15 @@ async function runBackupInternal() {
}
const schemaVersion = await getCurrentSchemaVersion();
const [insertedId] = await db('backup_runs').insert({
const insertResult = await db('backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: 'scheduled',
app_version: packageJson.version,
node_version: process.version,
db_schema_version: schemaVersion
});
runId = insertedId;
}).returning('id');
runId = insertResult[0]?.id || insertResult[0];
const files = await service.getFilesToBackup(config.backup_include_archived);
logger.info(`Found ${files.length} files to check for backup`);
@@ -820,11 +820,17 @@ async function runBackupInternal() {
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null,
statistics: JSON.stringify({
// Use snake_case for frontend compatibility
files_processed: result.backedUpCount,
total_size: result.backedUpSize,
total_files_checked: files.length,
average_file_size: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
destination: destinationType,
// Keep camelCase for backward compatibility
totalFilesChecked: files.length,
filesBackedUp: result.backedUpCount,
totalSize: result.backedUpSize,
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
destination: destinationType
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0
})
});
@@ -927,22 +933,54 @@ async function triggerManualBackup() {
async function getBackupStatus(limit = 10) {
try {
const runs = await db('backup_runs')
const rawRuns = await db('backup_runs')
.orderBy('started_at', 'desc')
.limit(limit);
// Transform runs to add frontend-compatible field aliases
const runs = rawRuns.map(run => {
// Parse and transform statistics to snake_case for frontend compatibility
let statistics = run.statistics;
if (statistics) {
// Handle both string (SQLite) and object (PostgreSQL JSONB) types
let stats = statistics;
if (typeof statistics === 'string') {
try {
stats = JSON.parse(statistics);
} catch (e) {
stats = {};
}
}
// Add snake_case aliases for frontend
statistics = {
...stats,
files_processed: stats.filesBackedUp || stats.files_processed || 0,
total_size: stats.totalSize || stats.total_size || 0,
total_files_checked: stats.totalFilesChecked || stats.total_files_checked || 0,
average_file_size: stats.averageFileSize || stats.average_file_size || 0
};
}
return {
...run,
created_at: run.started_at, // Alias for frontend compatibility
statistics
};
});
const lastRun = runs[0];
let manifestValid = false;
if (lastRun && lastRun.manifest_path) {
try {
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
if (backupManifest.validateManifest) {
backupManifest.validateManifest(manifest);
// Use validateBackupManifest which handles both local and S3 paths
const result = await validateBackupManifest(lastRun.manifest_path);
manifestValid = result.valid;
if (!result.valid) {
logger.warn('Manifest validation failed:', result.error);
}
manifestValid = true;
} catch (error) {
logger.warn('Manifest validation failed:', error);
logger.warn('Manifest validation failed:', error.message);
}
}
@@ -951,6 +989,7 @@ async function getBackupStatus(limit = 10) {
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
lastRun: lastRun ? { ...lastRun, manifestValid } : null,
recentRuns: runs,
recentBackups: runs, // Alias for frontend compatibility
nextScheduledRun: getNextScheduledRun()
};
} catch (error) {
+3
View File
@@ -153,6 +153,9 @@ async function processTemplate(template, variables, language = 'en') {
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
if (processedVariables.expires_at) {
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
+45 -17
View File
@@ -75,14 +75,15 @@ class RestoreService {
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
// Create restore run record
const [runId] = await db('restore_runs').insert({
const result = await db('restore_runs').insert({
started_at: startTime,
status: 'running',
restore_type: options.restoreType,
source: options.source,
manifest_path: options.manifestPath,
is_dry_run: options.dryRun || false
});
}).returning('id');
const runId = Array.isArray(result) ? (result[0]?.id || result[0]) : result;
restoreRun = { id: runId };
@@ -105,7 +106,8 @@ class RestoreService {
if (validation.warnings.length > 0) {
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings });
if (!options.force) {
// Only block actual restores (not dry runs/validations) on warnings
if (!options.force && !options.dryRun) {
throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`);
}
}
@@ -400,29 +402,55 @@ class RestoreService {
* Check available disk space
*/
async checkDiskSpace(manifest, options) {
const { statvfs } = require('fs');
const statvfsAsync = promisify(statvfs);
try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const stats = await statvfsAsync(storagePath);
const blockSize = stats.bsize || stats.f_bsize || 4096;
const availableBytes = stats.bavail * blockSize;
// Calculate required space (with 20% buffer)
let requiredBytes = 0;
if (options.restoreType === 'full' || options.restoreType === 'files') {
requiredBytes = manifest.files.total_size * 1.2;
requiredBytes = (manifest.files?.total_size || 0) * 1.2;
}
if (options.restoreType === 'full' || options.restoreType === 'database') {
requiredBytes += (manifest.database.size || 0) * 1.2;
requiredBytes += (manifest.database?.size || 0) * 1.2;
}
// Try to get disk space using df command (works on Linux and macOS)
let availableBytes = 0;
let diskCheckSucceeded = false;
try {
const { exec } = require('child_process');
const execAsync = promisify(exec);
// Use root path as fallback if storage path doesn't exist yet
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
const parsed = parseInt(stdout.trim());
if (!isNaN(parsed) && parsed > 0) {
availableBytes = parsed * 1024; // Convert from KB to bytes
diskCheckSucceeded = true;
}
} catch (dfError) {
this.log('warn', 'Could not determine available disk space', { error: dfError.message });
}
// If disk check failed, return optimistic result
if (!diskCheckSucceeded) {
return {
hasEnoughSpace: true,
availableBytes: null, // null indicates unknown
requiredBytes,
availableFormatted: 'Unknown',
requiredFormatted: this.formatBytes(requiredBytes)
};
}
// Add space for pre-restore backup
if (!options.skipPreBackup) {
const currentUsage = await this.calculateCurrentStorageUsage();
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
try {
const currentUsage = await this.calculateCurrentStorageUsage();
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
} catch (e) {
// Ignore errors calculating current usage
}
}
return {
@@ -434,11 +462,11 @@ class RestoreService {
};
} catch (error) {
// Fallback for systems without statvfs
// Fallback for any errors
this.log('warn', 'Could not check disk space', { error: error.message });
return {
hasEnoughSpace: true, // Assume we have space if we can't check
availableBytes: 0,
availableBytes: null,
requiredBytes: 0,
availableFormatted: 'Unknown',
requiredFormatted: 'Unknown'
+16 -6
View File
@@ -76,12 +76,22 @@ class S3StorageAdapter extends stream.EventEmitter {
// Add custom endpoint if provided (for S3-compatible services)
if (this.config.endpoint) {
s3Config.endpoint = this.config.endpoint;
// For MinIO and other S3-compatible services
if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) {
s3Config.endpoint = `https://${this.config.endpoint}`;
} else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) {
s3Config.endpoint = `http://${this.config.endpoint}`;
let endpoint = this.config.endpoint;
// Only add protocol if endpoint doesn't already have one
const hasProtocol = endpoint.startsWith('http://') || endpoint.startsWith('https://');
if (!hasProtocol) {
// Add protocol based on sslEnabled setting
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
}
s3Config.endpoint = endpoint;
// For S3-compatible services with custom endpoints, force path style
// This is required for MinIO and when using IP addresses
if (!s3Config.forcePathStyle) {
s3Config.forcePathStyle = true;
logger.info('Automatically enabling forcePathStyle for custom S3 endpoint');
}
}
@@ -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
};