feat: add multi-administrator support with RBAC and fix backup/restore for S3
## Multi-Administrator System
- Add role-based access control (RBAC) with predefined roles (Super Admin, Admin, Editor, Viewer)
- Add granular permissions system for all admin operations
- Add admin user management page with invite functionality
- Add email invitation system for new administrators
- Add permission middleware protecting all admin routes
- Add PermissionGate component for frontend permission checks
- Track event creator (created_by) for audit purposes
## Backup & Restore Fixes
- Fix S3 backup: endpoint URL handling, manifest loading, field name compatibility
- Fix S3 restore: add list-backups endpoint, transform S3 config from frontend format
- Fix PostgreSQL compatibility: add .returning('id') for insert operations
- Fix disk space check: use df command, handle unknown space gracefully
- Fix dry-run validation to not block on warnings
- Fix req.user → req.admin in restore routes
## Database Migrations
- 054: Add roles table with predefined roles
- 055: Add permissions table
- 056: Add role_permissions junction table
- 057: Add role_id to admin_users
- 058: Add admin_invitations table
- 059: Add admin email templates
- 060: Add created_by to events table
## Other Improvements
- Update .gitignore to exclude planning docs and local backup directory
- Remove SQLite database file from tracking
- Add i18n translations for user management (EN/DE)
This commit is contained in:
@@ -81,9 +81,14 @@ CLAUDE.md
|
|||||||
BUGS_AND_FEATURES.md
|
BUGS_AND_FEATURES.md
|
||||||
frontend/TEST_PLAN.md
|
frontend/TEST_PLAN.md
|
||||||
docs/REFACTORING_PLAN.md
|
docs/REFACTORING_PLAN.md
|
||||||
|
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
|
||||||
|
docs/*_PLAN.md
|
||||||
docs/test-*.md
|
docs/test-*.md
|
||||||
docs/feature-*.md
|
docs/feature-*.md
|
||||||
|
|
||||||
|
# Local backup directory (from testing)
|
||||||
|
backup/
|
||||||
|
|
||||||
# Local artifacts from browser tooling
|
# Local artifacts from browser tooling
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
|
||||||
|
|||||||
+41
-32
@@ -450,6 +450,20 @@ [email protected]
|
|||||||
|
|
||||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||||
|
|
||||||
|
### Routing Schema
|
||||||
|
|
||||||
|
PicPeak consists of two services that need to be routed correctly:
|
||||||
|
|
||||||
|
| Path | Service | Port | Description |
|
||||||
|
|------|---------|------|-------------|
|
||||||
|
| `/api/*` | Backend | 3001 | All API endpoints |
|
||||||
|
| `/photos/*` | Backend | 3001 | Protected photo files |
|
||||||
|
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
|
||||||
|
| `/uploads/*` | Backend | 3001 | Upload files |
|
||||||
|
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
|
||||||
|
|
||||||
|
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
|
||||||
|
|
||||||
### Option 1: Nginx
|
### Option 1: Nginx
|
||||||
|
|
||||||
Install nginx and create `/etc/nginx/sites-available/picpeak`:
|
Install nginx and create `/etc/nginx/sites-available/picpeak`:
|
||||||
@@ -468,39 +482,32 @@ server {
|
|||||||
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
|
||||||
# Frontend
|
# Backend: API endpoints
|
||||||
location / {
|
location /api/ {
|
||||||
proxy_pass http://localhost:3000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Frontend (serves UI and /admin/*)
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:3000;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
# Backend API and protected resources
|
|
||||||
location /api {
|
|
||||||
proxy_pass http://localhost:3001;
|
proxy_pass http://localhost:3001;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
location ~ ^/(photos|thumbnails|uploads) {
|
|
||||||
|
# Backend: Protected media files
|
||||||
|
location ~ ^/(photos|thumbnails|uploads)/ {
|
||||||
proxy_pass http://localhost:3001;
|
proxy_pass http://localhost:3001;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Frontend: Everything else (React SPA)
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -530,10 +537,16 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
# API endpoints
|
||||||
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
|
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
|
||||||
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||||
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
|
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
|
||||||
|
# Protected media files
|
||||||
|
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
|
||||||
|
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 3: Caddy
|
### Option 3: Caddy
|
||||||
@@ -542,21 +555,12 @@ Create a `Caddyfile`:
|
|||||||
|
|
||||||
```caddyfile
|
```caddyfile
|
||||||
your-domain.com {
|
your-domain.com {
|
||||||
# Frontend
|
# Backend: API endpoints
|
||||||
handle /* {
|
|
||||||
reverse_proxy localhost:3000
|
|
||||||
}
|
|
||||||
|
|
||||||
# Backend API and admin
|
|
||||||
handle /api/* {
|
handle /api/* {
|
||||||
reverse_proxy localhost:3001
|
reverse_proxy localhost:3001
|
||||||
}
|
}
|
||||||
|
|
||||||
handle /admin/* {
|
|
||||||
reverse_proxy localhost:3001
|
|
||||||
}
|
|
||||||
|
|
||||||
# Protected resources
|
# Backend: Protected media files
|
||||||
handle /photos/* {
|
handle /photos/* {
|
||||||
reverse_proxy localhost:3001
|
reverse_proxy localhost:3001
|
||||||
}
|
}
|
||||||
@@ -568,6 +572,11 @@ your-domain.com {
|
|||||||
handle /uploads/* {
|
handle /uploads/* {
|
||||||
reverse_proxy localhost:3001
|
reverse_proxy localhost:3001
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
|
||||||
|
handle {
|
||||||
|
reverse_proxy localhost:3000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -12,7 +12,8 @@ LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
|||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
|
|
||||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||||
RUN npm install -g npm@latest
|
# Pin to npm 10.x which supports --omit=dev flag
|
||||||
|
RUN npm install -g npm@10
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -34,7 +35,8 @@ WORKDIR /app
|
|||||||
RUN apk upgrade --no-cache
|
RUN apk upgrade --no-cache
|
||||||
|
|
||||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||||
RUN npm install -g npm@latest
|
# Pin to npm 10.x which supports --omit=dev flag
|
||||||
|
RUN npm install -g npm@10
|
||||||
|
|
||||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||||
RUN apk add --no-cache dumb-init postgresql-client
|
RUN apk add --no-cache dumb-init postgresql-client
|
||||||
@@ -46,8 +48,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
|||||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||||
COPY --chown=nodejs:nodejs . .
|
COPY --chown=nodejs:nodejs . .
|
||||||
|
|
||||||
# Make wait script executable
|
# Ensure all source files are readable and wait script is executable
|
||||||
RUN chmod +x wait-for-db.sh
|
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
|
||||||
|
|
||||||
# Create necessary directories
|
# Create necessary directories
|
||||||
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
|
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add Roles Table
|
||||||
|
* Creates the roles table for RBAC multi-administrator support.
|
||||||
|
*
|
||||||
|
* Default roles:
|
||||||
|
* - super_admin (priority 100): Full system access including user management
|
||||||
|
* - admin (priority 80): Full event and photo management
|
||||||
|
* - editor (priority 50): Can edit events and photos but not create or delete
|
||||||
|
* - viewer (priority 20): Read-only access to dashboard and events
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Creating roles table...');
|
||||||
|
|
||||||
|
// Check if table already exists
|
||||||
|
const hasRolesTable = await knex.schema.hasTable('roles');
|
||||||
|
|
||||||
|
if (!hasRolesTable) {
|
||||||
|
await knex.schema.createTable('roles', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name', 50).unique().notNullable(); // 'super_admin', 'admin', 'editor', 'viewer'
|
||||||
|
table.string('display_name', 100).notNullable(); // 'Super Admin', 'Admin', etc.
|
||||||
|
table.text('description');
|
||||||
|
table.boolean('is_system').defaultTo(false); // System roles cannot be deleted
|
||||||
|
table.integer('priority').defaultTo(0); // Higher = more privileged (for hierarchy)
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// Index for name lookups
|
||||||
|
table.index(['name']);
|
||||||
|
// Index for priority-based ordering
|
||||||
|
table.index(['priority']);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Roles table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert default system roles
|
||||||
|
const existingRoles = await knex('roles').select('name');
|
||||||
|
const existingRoleNames = existingRoles.map(r => r.name);
|
||||||
|
|
||||||
|
const defaultRoles = [
|
||||||
|
{
|
||||||
|
name: 'super_admin',
|
||||||
|
display_name: 'Super Admin',
|
||||||
|
description: 'Full system access including user management',
|
||||||
|
is_system: true,
|
||||||
|
priority: 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'admin',
|
||||||
|
display_name: 'Admin',
|
||||||
|
description: 'Full event and photo management',
|
||||||
|
is_system: true,
|
||||||
|
priority: 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'editor',
|
||||||
|
display_name: 'Editor',
|
||||||
|
description: 'Can edit events and photos but not create or delete',
|
||||||
|
is_system: true,
|
||||||
|
priority: 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'viewer',
|
||||||
|
display_name: 'Viewer',
|
||||||
|
description: 'Read-only access to dashboard and events',
|
||||||
|
is_system: true,
|
||||||
|
priority: 20
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const rolesToInsert = defaultRoles.filter(role => !existingRoleNames.includes(role.name));
|
||||||
|
|
||||||
|
if (rolesToInsert.length > 0) {
|
||||||
|
await knex('roles').insert(rolesToInsert);
|
||||||
|
console.log(`Inserted ${rolesToInsert.length} default roles`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Roles table migration completed successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing roles table...');
|
||||||
|
|
||||||
|
// Note: This will fail if there are foreign key references
|
||||||
|
// The role_permissions and admin_users tables must be rolled back first
|
||||||
|
await knex.schema.dropTableIfExists('roles');
|
||||||
|
|
||||||
|
console.log('Roles table removed');
|
||||||
|
};
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add Permissions Table
|
||||||
|
* Creates the permissions table for granular access control.
|
||||||
|
*
|
||||||
|
* Permission categories:
|
||||||
|
* - events: View, create, edit, delete, archive events
|
||||||
|
* - photos: View, upload, edit, delete, download photos
|
||||||
|
* - archives: View, restore, download, delete archives
|
||||||
|
* - analytics: View analytics and statistics
|
||||||
|
* - email: View, edit, send emails
|
||||||
|
* - branding: View and edit branding settings
|
||||||
|
* - cms: View and edit CMS pages
|
||||||
|
* - settings: View and edit application settings
|
||||||
|
* - backup: View, create, restore, delete backups
|
||||||
|
* - users: View, create, edit, delete admin users (Super Admin only)
|
||||||
|
* - activity: View and export activity logs
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Creating permissions table...');
|
||||||
|
|
||||||
|
// Check if table already exists
|
||||||
|
const hasPermissionsTable = await knex.schema.hasTable('permissions');
|
||||||
|
|
||||||
|
if (!hasPermissionsTable) {
|
||||||
|
await knex.schema.createTable('permissions', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name', 100).unique().notNullable(); // 'events.create', 'users.manage', etc.
|
||||||
|
table.string('display_name', 150).notNullable();
|
||||||
|
table.string('category', 50).notNullable(); // 'events', 'photos', 'users', 'settings'
|
||||||
|
table.text('description');
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// Indexes for efficient lookups
|
||||||
|
table.index(['name']);
|
||||||
|
table.index(['category']);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Permissions table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for existing permissions
|
||||||
|
const existingPermissions = await knex('permissions').select('name');
|
||||||
|
const existingPermissionNames = existingPermissions.map(p => p.name);
|
||||||
|
|
||||||
|
// Define all permissions
|
||||||
|
const permissions = [
|
||||||
|
// Events
|
||||||
|
{ name: 'events.view', display_name: 'View Events', category: 'events', description: 'View event list and details' },
|
||||||
|
{ name: 'events.create', display_name: 'Create Events', category: 'events', description: 'Create new events' },
|
||||||
|
{ name: 'events.edit', display_name: 'Edit Events', category: 'events', description: 'Edit existing events' },
|
||||||
|
{ name: 'events.delete', display_name: 'Delete Events', category: 'events', description: 'Delete events' },
|
||||||
|
{ name: 'events.archive', display_name: 'Archive Events', category: 'events', description: 'Archive and restore events' },
|
||||||
|
|
||||||
|
// Photos
|
||||||
|
{ name: 'photos.view', display_name: 'View Photos', category: 'photos', description: 'View photos in events' },
|
||||||
|
{ name: 'photos.upload', display_name: 'Upload Photos', category: 'photos', description: 'Upload photos to events' },
|
||||||
|
{ name: 'photos.edit', display_name: 'Edit Photos', category: 'photos', description: 'Edit photo metadata and categories' },
|
||||||
|
{ name: 'photos.delete', display_name: 'Delete Photos', category: 'photos', description: 'Delete photos from events' },
|
||||||
|
{ name: 'photos.download', display_name: 'Download Photos', category: 'photos', description: 'Download photos and bulk export' },
|
||||||
|
|
||||||
|
// Archives
|
||||||
|
{ name: 'archives.view', display_name: 'View Archives', category: 'archives', description: 'View archived events' },
|
||||||
|
{ name: 'archives.restore', display_name: 'Restore Archives', category: 'archives', description: 'Restore archived events' },
|
||||||
|
{ name: 'archives.download', display_name: 'Download Archives', category: 'archives', description: 'Download archive files' },
|
||||||
|
{ name: 'archives.delete', display_name: 'Delete Archives', category: 'archives', description: 'Permanently delete archives' },
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
{ name: 'analytics.view', display_name: 'View Analytics', category: 'analytics', description: 'View analytics and statistics' },
|
||||||
|
|
||||||
|
// Email
|
||||||
|
{ name: 'email.view', display_name: 'View Email Settings', category: 'email', description: 'View email configuration' },
|
||||||
|
{ name: 'email.edit', display_name: 'Edit Email Settings', category: 'email', description: 'Configure email settings and templates' },
|
||||||
|
{ name: 'email.send', display_name: 'Send Emails', category: 'email', description: 'Send and resend gallery emails' },
|
||||||
|
|
||||||
|
// Branding & CMS
|
||||||
|
{ name: 'branding.view', display_name: 'View Branding', category: 'branding', description: 'View branding settings' },
|
||||||
|
{ name: 'branding.edit', display_name: 'Edit Branding', category: 'branding', description: 'Edit branding and theme settings' },
|
||||||
|
{ name: 'cms.view', display_name: 'View CMS Pages', category: 'cms', description: 'View CMS content pages' },
|
||||||
|
{ name: 'cms.edit', display_name: 'Edit CMS Pages', category: 'cms', description: 'Edit CMS content pages' },
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
{ name: 'settings.view', display_name: 'View Settings', category: 'settings', description: 'View application settings' },
|
||||||
|
{ name: 'settings.edit', display_name: 'Edit Settings', category: 'settings', description: 'Modify application settings' },
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
{ name: 'backup.view', display_name: 'View Backups', category: 'backup', description: 'View backup status and history' },
|
||||||
|
{ name: 'backup.create', display_name: 'Create Backups', category: 'backup', description: 'Create new backups' },
|
||||||
|
{ name: 'backup.restore', display_name: 'Restore Backups', category: 'backup', description: 'Restore from backups' },
|
||||||
|
{ name: 'backup.delete', display_name: 'Delete Backups', category: 'backup', description: 'Delete backup files' },
|
||||||
|
|
||||||
|
// User Management (Super Admin only)
|
||||||
|
{ name: 'users.view', display_name: 'View Users', category: 'users', description: 'View admin user list' },
|
||||||
|
{ name: 'users.create', display_name: 'Create Users', category: 'users', description: 'Invite new admin users' },
|
||||||
|
{ name: 'users.edit', display_name: 'Edit Users', category: 'users', description: 'Edit admin user details and roles' },
|
||||||
|
{ name: 'users.delete', display_name: 'Delete Users', category: 'users', description: 'Deactivate or delete admin users' },
|
||||||
|
|
||||||
|
// Activity Logs
|
||||||
|
{ name: 'activity.view', display_name: 'View Activity Logs', category: 'activity', description: 'View system activity logs' },
|
||||||
|
{ name: 'activity.export', display_name: 'Export Activity Logs', category: 'activity', description: 'Export activity logs' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter out already existing permissions
|
||||||
|
const permissionsToInsert = permissions.filter(p => !existingPermissionNames.includes(p.name));
|
||||||
|
|
||||||
|
if (permissionsToInsert.length > 0) {
|
||||||
|
await knex('permissions').insert(permissionsToInsert);
|
||||||
|
console.log(`Inserted ${permissionsToInsert.length} permissions`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Permissions table migration completed successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing permissions table...');
|
||||||
|
|
||||||
|
// Note: This will fail if there are foreign key references
|
||||||
|
// The role_permissions table must be rolled back first
|
||||||
|
await knex.schema.dropTableIfExists('permissions');
|
||||||
|
|
||||||
|
console.log('Permissions table removed');
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add Role Permissions Junction Table
|
||||||
|
* Creates the junction table mapping permissions to roles.
|
||||||
|
*
|
||||||
|
* Role permission mappings:
|
||||||
|
* - super_admin: All permissions
|
||||||
|
* - admin: Events, Photos, Archives, Analytics, Email, Branding, CMS, Settings (view), Backup (view/create), Activity (view)
|
||||||
|
* - editor: View/Create/Edit own events and photos, Analytics (view), Activity (view)
|
||||||
|
* - viewer: View-only access to events, photos, archives, analytics, branding, cms
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Creating role_permissions junction table...');
|
||||||
|
|
||||||
|
// Check if table already exists
|
||||||
|
const hasRolePermissionsTable = await knex.schema.hasTable('role_permissions');
|
||||||
|
|
||||||
|
if (!hasRolePermissionsTable) {
|
||||||
|
await knex.schema.createTable('role_permissions', (table) => {
|
||||||
|
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE');
|
||||||
|
table.integer('permission_id').unsigned().references('id').inTable('permissions').onDelete('CASCADE');
|
||||||
|
table.primary(['role_id', 'permission_id']);
|
||||||
|
|
||||||
|
// Indexes for efficient lookups
|
||||||
|
table.index(['role_id']);
|
||||||
|
table.index(['permission_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Role permissions junction table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get role and permission IDs
|
||||||
|
const roles = await knex('roles').select('id', 'name');
|
||||||
|
const permissions = await knex('permissions').select('id', 'name');
|
||||||
|
|
||||||
|
if (roles.length === 0 || permissions.length === 0) {
|
||||||
|
console.log('No roles or permissions found, skipping permission mappings');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleMap = Object.fromEntries(roles.map(r => [r.name, r.id]));
|
||||||
|
const permMap = Object.fromEntries(permissions.map(p => [p.name, p.id]));
|
||||||
|
|
||||||
|
// Define role-permission mappings
|
||||||
|
const rolePermissions = {
|
||||||
|
super_admin: permissions.map(p => p.name), // All permissions
|
||||||
|
admin: [
|
||||||
|
// Events - full access
|
||||||
|
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
|
||||||
|
// Photos - full access
|
||||||
|
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
|
||||||
|
// Archives - full access
|
||||||
|
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
|
||||||
|
// Analytics - view only
|
||||||
|
'analytics.view',
|
||||||
|
// Email - full access
|
||||||
|
'email.view', 'email.edit', 'email.send',
|
||||||
|
// Branding - full access
|
||||||
|
'branding.view', 'branding.edit',
|
||||||
|
// CMS - full access
|
||||||
|
'cms.view', 'cms.edit',
|
||||||
|
// Settings - view only
|
||||||
|
'settings.view',
|
||||||
|
// Backup - view and create only
|
||||||
|
'backup.view', 'backup.create',
|
||||||
|
// Activity - view only
|
||||||
|
'activity.view'
|
||||||
|
],
|
||||||
|
editor: [
|
||||||
|
// Events - view, create, and edit (can only see their own events)
|
||||||
|
'events.view', 'events.create', 'events.edit',
|
||||||
|
// Photos - view, upload, edit (no delete)
|
||||||
|
'photos.view', 'photos.upload', 'photos.edit',
|
||||||
|
// Analytics - view only
|
||||||
|
'analytics.view',
|
||||||
|
// Activity - view only
|
||||||
|
'activity.view'
|
||||||
|
],
|
||||||
|
viewer: [
|
||||||
|
// Events - view only
|
||||||
|
'events.view',
|
||||||
|
// Photos - view only
|
||||||
|
'photos.view',
|
||||||
|
// Archives - view only
|
||||||
|
'archives.view',
|
||||||
|
// Analytics - view only
|
||||||
|
'analytics.view',
|
||||||
|
// Branding - view only
|
||||||
|
'branding.view',
|
||||||
|
// CMS - view only
|
||||||
|
'cms.view'
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for existing mappings to avoid duplicates
|
||||||
|
const existingMappings = await knex('role_permissions').select('role_id', 'permission_id');
|
||||||
|
const existingSet = new Set(existingMappings.map(m => `${m.role_id}-${m.permission_id}`));
|
||||||
|
|
||||||
|
// Build insert list
|
||||||
|
const inserts = [];
|
||||||
|
for (const [roleName, perms] of Object.entries(rolePermissions)) {
|
||||||
|
for (const permName of perms) {
|
||||||
|
if (roleMap[roleName] && permMap[permName]) {
|
||||||
|
const key = `${roleMap[roleName]}-${permMap[permName]}`;
|
||||||
|
if (!existingSet.has(key)) {
|
||||||
|
inserts.push({
|
||||||
|
role_id: roleMap[roleName],
|
||||||
|
permission_id: permMap[permName]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inserts.length > 0) {
|
||||||
|
// Insert in batches to avoid hitting database limits
|
||||||
|
const batchSize = 50;
|
||||||
|
for (let i = 0; i < inserts.length; i += batchSize) {
|
||||||
|
const batch = inserts.slice(i, i + batchSize);
|
||||||
|
await knex('role_permissions').insert(batch);
|
||||||
|
}
|
||||||
|
console.log(`Inserted ${inserts.length} role-permission mappings`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Role permissions junction table migration completed successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing role_permissions junction table...');
|
||||||
|
|
||||||
|
await knex.schema.dropTableIfExists('role_permissions');
|
||||||
|
|
||||||
|
console.log('Role permissions junction table removed');
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add Role to Admin Users
|
||||||
|
* Adds RBAC-related columns to the admin_users table:
|
||||||
|
* - role_id: Foreign key to roles table
|
||||||
|
* - created_by: Foreign key to admin_users (who invited this user)
|
||||||
|
* - invite_token: Token for invitation acceptance (64 chars = 256 bits)
|
||||||
|
* - invite_expires_at: When the invitation token expires
|
||||||
|
* - invite_accepted_at: When the user accepted the invitation
|
||||||
|
*
|
||||||
|
* Also migrates existing admin users to super_admin role.
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Adding role columns to admin_users table...');
|
||||||
|
|
||||||
|
// Check if columns already exist
|
||||||
|
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
|
||||||
|
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
|
||||||
|
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
|
||||||
|
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
|
||||||
|
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
|
||||||
|
|
||||||
|
// Add new columns if they don't exist
|
||||||
|
if (!hasRoleId || !hasCreatedBy || !hasInviteToken || !hasInviteExpiresAt || !hasInviteAcceptedAt) {
|
||||||
|
await knex.schema.alterTable('admin_users', (table) => {
|
||||||
|
if (!hasRoleId) {
|
||||||
|
// Note: We add as nullable first, then set values, then alter to not null
|
||||||
|
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL');
|
||||||
|
}
|
||||||
|
if (!hasCreatedBy) {
|
||||||
|
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
|
||||||
|
}
|
||||||
|
if (!hasInviteToken) {
|
||||||
|
// 64 characters = 32 bytes hex = 256 bits of entropy (cryptographically secure)
|
||||||
|
table.string('invite_token', 64);
|
||||||
|
}
|
||||||
|
if (!hasInviteExpiresAt) {
|
||||||
|
table.timestamp('invite_expires_at');
|
||||||
|
}
|
||||||
|
if (!hasInviteAcceptedAt) {
|
||||||
|
table.timestamp('invite_accepted_at');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Role columns added to admin_users table');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add index on invite_token for fast lookup
|
||||||
|
const hasInviteTokenIndex = await knex.schema.hasColumn('admin_users', 'invite_token');
|
||||||
|
if (hasInviteTokenIndex) {
|
||||||
|
// Create index if it doesn't exist (safe for both PostgreSQL and SQLite)
|
||||||
|
try {
|
||||||
|
await knex.schema.alterTable('admin_users', (table) => {
|
||||||
|
table.index(['invite_token']);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Index may already exist
|
||||||
|
if (!e.message.includes('already exists')) {
|
||||||
|
console.log('Note: invite_token index may already exist');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get super_admin role ID
|
||||||
|
const superAdminRole = await knex('roles').where('name', 'super_admin').first();
|
||||||
|
|
||||||
|
if (superAdminRole) {
|
||||||
|
// Migrate existing admin users without a role to super_admin
|
||||||
|
const usersWithoutRole = await knex('admin_users')
|
||||||
|
.whereNull('role_id')
|
||||||
|
.select('id');
|
||||||
|
|
||||||
|
if (usersWithoutRole.length > 0) {
|
||||||
|
await knex('admin_users')
|
||||||
|
.whereNull('role_id')
|
||||||
|
.update({ role_id: superAdminRole.id });
|
||||||
|
|
||||||
|
console.log(`Migrated ${usersWithoutRole.length} existing admin user(s) to super_admin role`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Warning: super_admin role not found. Run migration 054 first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Admin users role migration completed successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing role columns from admin_users table...');
|
||||||
|
|
||||||
|
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
|
||||||
|
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
|
||||||
|
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
|
||||||
|
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
|
||||||
|
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
|
||||||
|
|
||||||
|
await knex.schema.alterTable('admin_users', (table) => {
|
||||||
|
if (hasInviteAcceptedAt) {
|
||||||
|
table.dropColumn('invite_accepted_at');
|
||||||
|
}
|
||||||
|
if (hasInviteExpiresAt) {
|
||||||
|
table.dropColumn('invite_expires_at');
|
||||||
|
}
|
||||||
|
if (hasInviteToken) {
|
||||||
|
table.dropColumn('invite_token');
|
||||||
|
}
|
||||||
|
if (hasCreatedBy) {
|
||||||
|
table.dropColumn('created_by');
|
||||||
|
}
|
||||||
|
if (hasRoleId) {
|
||||||
|
table.dropColumn('role_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Role columns removed from admin_users table');
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add Admin Invitations Table
|
||||||
|
* Creates the admin_invitations table for managing pending admin user invitations.
|
||||||
|
*
|
||||||
|
* Security features:
|
||||||
|
* - Token is 64 characters (32 bytes hex = 256 bits of entropy)
|
||||||
|
* - Tokens are unique and indexed for fast lookup
|
||||||
|
* - Invitations have expiration timestamps
|
||||||
|
* - Tracks who invited whom and when accepted
|
||||||
|
* - Foreign key constraints with appropriate CASCADE behavior
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Creating admin_invitations table...');
|
||||||
|
|
||||||
|
// Check if table already exists
|
||||||
|
const hasAdminInvitationsTable = await knex.schema.hasTable('admin_invitations');
|
||||||
|
|
||||||
|
if (!hasAdminInvitationsTable) {
|
||||||
|
await knex.schema.createTable('admin_invitations', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
|
||||||
|
// Email of the invited user
|
||||||
|
table.string('email', 255).notNullable();
|
||||||
|
|
||||||
|
// Invitation token - 64 characters = 32 bytes hex = 256 bits of entropy
|
||||||
|
// Cryptographically secure for one-time use tokens
|
||||||
|
table.string('token', 64).unique().notNullable();
|
||||||
|
|
||||||
|
// Role to assign when invitation is accepted
|
||||||
|
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE').notNullable();
|
||||||
|
|
||||||
|
// Who created this invitation
|
||||||
|
table.integer('invited_by').unsigned().references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
|
||||||
|
|
||||||
|
// When the invitation expires (typically 7 days from creation)
|
||||||
|
table.timestamp('expires_at').notNullable();
|
||||||
|
|
||||||
|
// When the invitation was accepted (null if pending)
|
||||||
|
table.timestamp('accepted_at');
|
||||||
|
|
||||||
|
// The admin_user ID created when invitation was accepted (for audit trail)
|
||||||
|
table.integer('accepted_user_id').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
|
||||||
|
|
||||||
|
// When the invitation was created
|
||||||
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
|
||||||
|
// Indexes for efficient lookups
|
||||||
|
table.index(['token']); // Fast token validation
|
||||||
|
table.index(['email']); // Check for existing invitations by email
|
||||||
|
table.index(['expires_at']); // Cleanup expired invitations
|
||||||
|
table.index(['invited_by']); // List invitations by inviter
|
||||||
|
table.index(['accepted_at']); // Filter pending vs accepted
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Admin invitations table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Admin invitations table migration completed successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing admin_invitations table...');
|
||||||
|
|
||||||
|
await knex.schema.dropTableIfExists('admin_invitations');
|
||||||
|
|
||||||
|
console.log('Admin invitations table removed');
|
||||||
|
};
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/**
|
||||||
|
* Migration to add email templates for admin invitation and password reset
|
||||||
|
* These templates support the RBAC (Role-Based Access Control) feature
|
||||||
|
*/
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// Check which templates already exist
|
||||||
|
const existingTemplates = await knex('email_templates')
|
||||||
|
.select('template_key')
|
||||||
|
.whereIn('template_key', ['admin_invitation', 'admin_password_reset']);
|
||||||
|
|
||||||
|
const existingKeys = existingTemplates.map(t => t.template_key);
|
||||||
|
|
||||||
|
// Admin Invitation Email Template
|
||||||
|
if (!existingKeys.includes('admin_invitation')) {
|
||||||
|
await knex('email_templates').insert({
|
||||||
|
template_key: 'admin_invitation',
|
||||||
|
subject_en: 'You have been invited to join PicPeak as {{role_name}}',
|
||||||
|
subject_de: 'Sie wurden eingeladen, PicPeak als {{role_name}} beizutreten',
|
||||||
|
body_html_en: `
|
||||||
|
<h2>Welcome to PicPeak!</h2>
|
||||||
|
|
||||||
|
<p>You have been invited to join the PicPeak photo sharing platform as a <strong>{{role_name}}</strong>.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0;"><strong>Your Role:</strong> {{role_name}}</p>
|
||||||
|
<p style="margin: 10px 0 0 0;">This role grants you access to manage and administer the photo sharing platform.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>To accept this invitation and set up your account, click the button below:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Accept Invitation</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0;"><strong>Important:</strong> This invitation expires on <strong>{{expires_at}}</strong>. Please accept the invitation before this date.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.</p>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 13px; margin-top: 30px;">
|
||||||
|
If the button above does not work, copy and paste this link into your browser:<br>
|
||||||
|
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
The PicPeak Team</p>`,
|
||||||
|
body_text_en: `Welcome to PicPeak!
|
||||||
|
|
||||||
|
You have been invited to join the PicPeak photo sharing platform as a {{role_name}}.
|
||||||
|
|
||||||
|
Your Role: {{role_name}}
|
||||||
|
This role grants you access to manage and administer the photo sharing platform.
|
||||||
|
|
||||||
|
To accept this invitation and set up your account, visit the following link:
|
||||||
|
{{invite_link}}
|
||||||
|
|
||||||
|
IMPORTANT: This invitation expires on {{expires_at}}. Please accept the invitation before this date.
|
||||||
|
|
||||||
|
If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The PicPeak Team`,
|
||||||
|
body_html_de: `
|
||||||
|
<h2>Willkommen bei PicPeak!</h2>
|
||||||
|
|
||||||
|
<p>Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als <strong>{{role_name}}</strong> beizutreten.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||||
|
<p style="margin: 0;"><strong>Ihre Rolle:</strong> {{role_name}}</p>
|
||||||
|
<p style="margin: 10px 0 0 0;">Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Um diese Einladung anzunehmen und Ihr Konto einzurichten, klicken Sie auf die Schaltflache unten:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Einladung annehmen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0;"><strong>Wichtig:</strong> Diese Einladung lauft am <strong>{{expires_at}}</strong> ab. Bitte nehmen Sie die Einladung vor diesem Datum an.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.</p>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 13px; margin-top: 30px;">
|
||||||
|
Wenn die Schaltflache oben nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
|
||||||
|
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>Mit freundlichen Grussen,<br>
|
||||||
|
Ihr PicPeak-Team</p>`,
|
||||||
|
body_text_de: `Willkommen bei PicPeak!
|
||||||
|
|
||||||
|
Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als {{role_name}} beizutreten.
|
||||||
|
|
||||||
|
Ihre Rolle: {{role_name}}
|
||||||
|
Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.
|
||||||
|
|
||||||
|
Um diese Einladung anzunehmen und Ihr Konto einzurichten, besuchen Sie den folgenden Link:
|
||||||
|
{{invite_link}}
|
||||||
|
|
||||||
|
WICHTIG: Diese Einladung lauft am {{expires_at}} ab. Bitte nehmen Sie die Einladung vor diesem Datum an.
|
||||||
|
|
||||||
|
Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.
|
||||||
|
|
||||||
|
Mit freundlichen Grussen,
|
||||||
|
Ihr PicPeak-Team`,
|
||||||
|
variables: JSON.stringify(['invite_link', 'role_name', 'expires_at'])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin Password Reset Email Template
|
||||||
|
if (!existingKeys.includes('admin_password_reset')) {
|
||||||
|
await knex('email_templates').insert({
|
||||||
|
template_key: 'admin_password_reset',
|
||||||
|
subject_en: 'Your PicPeak administrator password has been reset',
|
||||||
|
subject_de: 'Ihr PicPeak-Administratorpasswort wurde zuruckgesetzt',
|
||||||
|
body_html_en: `
|
||||||
|
<h2>Password Reset Notification</h2>
|
||||||
|
|
||||||
|
<p>Hello <strong>{{username}}</strong>,</p>
|
||||||
|
|
||||||
|
<p>Your administrator password for PicPeak has been reset by a system administrator.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Your New Login Credentials:</h3>
|
||||||
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Username:</strong> {{username}}</li>
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Temporary Password:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-weight: bold; font-size: 16px;">Security Notice</p>
|
||||||
|
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||||
|
<li>This is a temporary password. Please change it immediately after logging in.</li>
|
||||||
|
<li>Never share your password with anyone.</li>
|
||||||
|
<li>If you did not request this password reset, please contact your system administrator immediately.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>To log in to the admin panel, click the button below:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Log In Now</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 13px;">After logging in, navigate to your profile settings to change your password to something secure that only you know.</p>
|
||||||
|
|
||||||
|
<p>Best regards,<br>
|
||||||
|
The PicPeak Team</p>`,
|
||||||
|
body_text_en: `Password Reset Notification
|
||||||
|
|
||||||
|
Hello {{username}},
|
||||||
|
|
||||||
|
Your administrator password for PicPeak has been reset by a system administrator.
|
||||||
|
|
||||||
|
Your New Login Credentials:
|
||||||
|
- Username: {{username}}
|
||||||
|
- Temporary Password: {{new_password}}
|
||||||
|
|
||||||
|
SECURITY NOTICE:
|
||||||
|
- This is a temporary password. Please change it immediately after logging in.
|
||||||
|
- Never share your password with anyone.
|
||||||
|
- If you did not request this password reset, please contact your system administrator immediately.
|
||||||
|
|
||||||
|
To log in to the admin panel, visit: {{admin_login_url}}
|
||||||
|
|
||||||
|
After logging in, navigate to your profile settings to change your password to something secure that only you know.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
The PicPeak Team`,
|
||||||
|
body_html_de: `
|
||||||
|
<h2>Benachrichtigung uber Passwortzurucksetzung</h2>
|
||||||
|
|
||||||
|
<p>Hallo <strong>{{username}}</strong>,</p>
|
||||||
|
|
||||||
|
<p>Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Ihre neuen Anmeldedaten:</h3>
|
||||||
|
<ul style="list-style: none; padding: 0;">
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Benutzername:</strong> {{username}}</li>
|
||||||
|
<li style="margin-bottom: 10px;"><strong>Vorlaufiges Passwort:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0; font-weight: bold; font-size: 16px;">Sicherheitshinweis</p>
|
||||||
|
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
|
||||||
|
<li>Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.</li>
|
||||||
|
<li>Teilen Sie Ihr Passwort niemals mit anderen.</li>
|
||||||
|
<li>Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Um sich im Admin-Panel anzumelden, klicken Sie auf die Schaltflache unten:</p>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
|
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="color: #666; font-size: 13px;">Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.</p>
|
||||||
|
|
||||||
|
<p>Mit freundlichen Grussen,<br>
|
||||||
|
Ihr PicPeak-Team</p>`,
|
||||||
|
body_text_de: `Benachrichtigung uber Passwortzurucksetzung
|
||||||
|
|
||||||
|
Hallo {{username}},
|
||||||
|
|
||||||
|
Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.
|
||||||
|
|
||||||
|
Ihre neuen Anmeldedaten:
|
||||||
|
- Benutzername: {{username}}
|
||||||
|
- Vorlaufiges Passwort: {{new_password}}
|
||||||
|
|
||||||
|
SICHERHEITSHINWEIS:
|
||||||
|
- Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.
|
||||||
|
- Teilen Sie Ihr Passwort niemals mit anderen.
|
||||||
|
- Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.
|
||||||
|
|
||||||
|
Um sich im Admin-Panel anzumelden, besuchen Sie: {{admin_login_url}}
|
||||||
|
|
||||||
|
Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.
|
||||||
|
|
||||||
|
Mit freundlichen Grussen,
|
||||||
|
Ihr PicPeak-Team`,
|
||||||
|
variables: JSON.stringify(['username', 'new_password', 'admin_login_url'])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// Remove the admin email templates
|
||||||
|
await knex('email_templates')
|
||||||
|
.whereIn('template_key', ['admin_invitation', 'admin_password_reset'])
|
||||||
|
.delete();
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add created_by column to events table
|
||||||
|
* This allows filtering events by owner for role-based access control
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
// Add created_by column to events table
|
||||||
|
await knex.schema.alterTable('events', (table) => {
|
||||||
|
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set existing events to be owned by the first admin (super_admin)
|
||||||
|
const superAdmin = await knex('admin_users').where('role_id', 1).first();
|
||||||
|
if (superAdmin) {
|
||||||
|
await knex('events').update({ created_by: superAdmin.id });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex.schema.alterTable('events', (table) => {
|
||||||
|
table.dropColumn('created_by');
|
||||||
|
});
|
||||||
|
};
|
||||||
Generated
+120
-134
@@ -30,6 +30,7 @@
|
|||||||
"i18next": "25.3.2",
|
"i18next": "25.3.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"ipaddr.js": "^2.3.0",
|
||||||
"joi": "^17.9.1",
|
"joi": "^17.9.1",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
@@ -258,33 +259,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/client-s3": {
|
"node_modules/@aws-sdk/client-s3": {
|
||||||
"version": "3.962.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.962.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.964.0.tgz",
|
||||||
"integrity": "sha512-I2/1McBZCcM3PfM4ck8D6gnZR3K7+yl1fGkwTq/3ThEn9tdLjNwcdgTbPfxfX6LoecLrH9Ekoo+D9nmQ0T261w==",
|
"integrity": "sha512-mDK+3qpfHnEPXeF6D8nQkJOkOvchllQosgfxv0FK9PNBuU9WVkP8yj7y3YwH6JYTgy1ejz1Ju/YfoUbbE6m7zw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/sha1-browser": "5.2.0",
|
"@aws-crypto/sha1-browser": "5.2.0",
|
||||||
"@aws-crypto/sha256-browser": "5.2.0",
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
"@aws-crypto/sha256-js": "5.2.0",
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-node": "3.962.0",
|
"@aws-sdk/credential-provider-node": "3.964.0",
|
||||||
"@aws-sdk/middleware-bucket-endpoint": "3.957.0",
|
"@aws-sdk/middleware-bucket-endpoint": "3.957.0",
|
||||||
"@aws-sdk/middleware-expect-continue": "3.957.0",
|
"@aws-sdk/middleware-expect-continue": "3.957.0",
|
||||||
"@aws-sdk/middleware-flexible-checksums": "3.957.0",
|
"@aws-sdk/middleware-flexible-checksums": "3.964.0",
|
||||||
"@aws-sdk/middleware-host-header": "3.957.0",
|
"@aws-sdk/middleware-host-header": "3.957.0",
|
||||||
"@aws-sdk/middleware-location-constraint": "3.957.0",
|
"@aws-sdk/middleware-location-constraint": "3.957.0",
|
||||||
"@aws-sdk/middleware-logger": "3.957.0",
|
"@aws-sdk/middleware-logger": "3.957.0",
|
||||||
"@aws-sdk/middleware-recursion-detection": "3.957.0",
|
"@aws-sdk/middleware-recursion-detection": "3.957.0",
|
||||||
"@aws-sdk/middleware-sdk-s3": "3.957.0",
|
"@aws-sdk/middleware-sdk-s3": "3.964.0",
|
||||||
"@aws-sdk/middleware-ssec": "3.957.0",
|
"@aws-sdk/middleware-ssec": "3.957.0",
|
||||||
"@aws-sdk/middleware-user-agent": "3.957.0",
|
"@aws-sdk/middleware-user-agent": "3.964.0",
|
||||||
"@aws-sdk/region-config-resolver": "3.957.0",
|
"@aws-sdk/region-config-resolver": "3.957.0",
|
||||||
"@aws-sdk/signature-v4-multi-region": "3.957.0",
|
"@aws-sdk/signature-v4-multi-region": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@aws-sdk/util-endpoints": "3.957.0",
|
"@aws-sdk/util-endpoints": "3.957.0",
|
||||||
"@aws-sdk/util-user-agent-browser": "3.957.0",
|
"@aws-sdk/util-user-agent-browser": "3.957.0",
|
||||||
"@aws-sdk/util-user-agent-node": "3.957.0",
|
"@aws-sdk/util-user-agent-node": "3.964.0",
|
||||||
"@smithy/config-resolver": "^4.4.5",
|
"@smithy/config-resolver": "^4.4.5",
|
||||||
"@smithy/core": "^3.20.0",
|
"@smithy/core": "^3.20.0",
|
||||||
"@smithy/eventstream-serde-browser": "^4.2.7",
|
"@smithy/eventstream-serde-browser": "^4.2.7",
|
||||||
@@ -325,23 +326,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/client-sso": {
|
"node_modules/@aws-sdk/client-sso": {
|
||||||
"version": "3.958.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.964.0.tgz",
|
||||||
"integrity": "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==",
|
"integrity": "sha512-IenVyY8Io2CwBgmS22xk/H5LibmSbvLnPA9oFqLORO6Ji1Ks8z/ow+ud/ZurVjFekz3LD/uxVFX3ZKGo6N7Byw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/sha256-browser": "5.2.0",
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
"@aws-crypto/sha256-js": "5.2.0",
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/middleware-host-header": "3.957.0",
|
"@aws-sdk/middleware-host-header": "3.957.0",
|
||||||
"@aws-sdk/middleware-logger": "3.957.0",
|
"@aws-sdk/middleware-logger": "3.957.0",
|
||||||
"@aws-sdk/middleware-recursion-detection": "3.957.0",
|
"@aws-sdk/middleware-recursion-detection": "3.957.0",
|
||||||
"@aws-sdk/middleware-user-agent": "3.957.0",
|
"@aws-sdk/middleware-user-agent": "3.964.0",
|
||||||
"@aws-sdk/region-config-resolver": "3.957.0",
|
"@aws-sdk/region-config-resolver": "3.957.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@aws-sdk/util-endpoints": "3.957.0",
|
"@aws-sdk/util-endpoints": "3.957.0",
|
||||||
"@aws-sdk/util-user-agent-browser": "3.957.0",
|
"@aws-sdk/util-user-agent-browser": "3.957.0",
|
||||||
"@aws-sdk/util-user-agent-node": "3.957.0",
|
"@aws-sdk/util-user-agent-node": "3.964.0",
|
||||||
"@smithy/config-resolver": "^4.4.5",
|
"@smithy/config-resolver": "^4.4.5",
|
||||||
"@smithy/core": "^3.20.0",
|
"@smithy/core": "^3.20.0",
|
||||||
"@smithy/fetch-http-handler": "^5.3.8",
|
"@smithy/fetch-http-handler": "^5.3.8",
|
||||||
@@ -374,9 +375,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/core": {
|
"node_modules/@aws-sdk/core": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.964.0.tgz",
|
||||||
"integrity": "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==",
|
"integrity": "sha512-1gIfbt0KRxI8am1UYFcIxQ5QKb22JyN3k52sxyrKXJYC8Knn/rTUAZbYti45CfETe5PLadInGvWqClwGRlZKNg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
@@ -411,12 +412,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-env": {
|
"node_modules/@aws-sdk/credential-provider-env": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.964.0.tgz",
|
||||||
"integrity": "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==",
|
"integrity": "sha512-jWNSXOOBMYuxzI2rXi8x91YL07dhomyGzzh0CdaLej0LRmknmDrZcZNkVpa7Fredy1PFcmOlokwCS5PmZMN8ZQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/property-provider": "^4.2.7",
|
"@smithy/property-provider": "^4.2.7",
|
||||||
"@smithy/types": "^4.11.0",
|
"@smithy/types": "^4.11.0",
|
||||||
@@ -427,12 +428,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-http": {
|
"node_modules/@aws-sdk/credential-provider-http": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.964.0.tgz",
|
||||||
"integrity": "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==",
|
"integrity": "sha512-up7dl6vcaoXuYSwGXDvx8RnF8Lwj3jGChhyUR7krZOXLarIfUUN3ILOZnVNK5s/HnVNkEILlkdPvjhr9LVC1/Q==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/fetch-http-handler": "^5.3.8",
|
"@smithy/fetch-http-handler": "^5.3.8",
|
||||||
"@smithy/node-http-handler": "^4.4.7",
|
"@smithy/node-http-handler": "^4.4.7",
|
||||||
@@ -448,19 +449,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||||
"version": "3.962.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.962.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.964.0.tgz",
|
||||||
"integrity": "sha512-h0kVnXLW2d3nxbcrR/Pfg3W/+YoCguasWz7/3nYzVqmdKarGrpJzaFdoZtLgvDSZ8VgWUC4lWOTcsDMV0UNqUQ==",
|
"integrity": "sha512-t4FN9qTWU4nXDU6EQ6jopvyhXw0dbQ3n+3g6x5hmc1ECFAqA+xmFd1i5LljdZCi79cUXHduQWwvW8RJHMf0qJw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-env": "3.957.0",
|
"@aws-sdk/credential-provider-env": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-http": "3.957.0",
|
"@aws-sdk/credential-provider-http": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-login": "3.962.0",
|
"@aws-sdk/credential-provider-login": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-process": "3.957.0",
|
"@aws-sdk/credential-provider-process": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-sso": "3.958.0",
|
"@aws-sdk/credential-provider-sso": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-web-identity": "3.958.0",
|
"@aws-sdk/credential-provider-web-identity": "3.964.0",
|
||||||
"@aws-sdk/nested-clients": "3.958.0",
|
"@aws-sdk/nested-clients": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/credential-provider-imds": "^4.2.7",
|
"@smithy/credential-provider-imds": "^4.2.7",
|
||||||
"@smithy/property-provider": "^4.2.7",
|
"@smithy/property-provider": "^4.2.7",
|
||||||
@@ -473,13 +474,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-login": {
|
"node_modules/@aws-sdk/credential-provider-login": {
|
||||||
"version": "3.962.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.962.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.964.0.tgz",
|
||||||
"integrity": "sha512-kHYH6Av2UifG3mPkpPUNRh/PuX6adaAcpmsclJdHdxlixMCRdh8GNeEihq480DC0GmfqdpoSf1w2CLmLLPIS6w==",
|
"integrity": "sha512-c64dmTizMkJXDRzN3NYPTmUpKxegr5lmLOYPeQ60Zcbft6HFwPme8Gwy8pNxO4gG1fw6Ja2Vu6fZuSTn8aDFOQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/nested-clients": "3.958.0",
|
"@aws-sdk/nested-clients": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/property-provider": "^4.2.7",
|
"@smithy/property-provider": "^4.2.7",
|
||||||
"@smithy/protocol-http": "^5.3.7",
|
"@smithy/protocol-http": "^5.3.7",
|
||||||
@@ -492,17 +493,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-node": {
|
"node_modules/@aws-sdk/credential-provider-node": {
|
||||||
"version": "3.962.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.962.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.964.0.tgz",
|
||||||
"integrity": "sha512-CS78NsWRxLa+nWqeWBEYMZTLacMFIXs1C5WJuM9kD05LLiWL32ksljoPsvNN24Bc7rCSQIIMx/U3KGvkDVZMVg==",
|
"integrity": "sha512-FHxDXPOj888/qc/X8s0x4aUBdp4Y3k9VePRehUJBWRhhTsAyuIJis5V0iQeY1qvtqHXYa2qd1EZHGJ3bTjHxSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/credential-provider-env": "3.957.0",
|
"@aws-sdk/credential-provider-env": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-http": "3.957.0",
|
"@aws-sdk/credential-provider-http": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-ini": "3.962.0",
|
"@aws-sdk/credential-provider-ini": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-process": "3.957.0",
|
"@aws-sdk/credential-provider-process": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-sso": "3.958.0",
|
"@aws-sdk/credential-provider-sso": "3.964.0",
|
||||||
"@aws-sdk/credential-provider-web-identity": "3.958.0",
|
"@aws-sdk/credential-provider-web-identity": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/credential-provider-imds": "^4.2.7",
|
"@smithy/credential-provider-imds": "^4.2.7",
|
||||||
"@smithy/property-provider": "^4.2.7",
|
"@smithy/property-provider": "^4.2.7",
|
||||||
@@ -515,12 +516,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-process": {
|
"node_modules/@aws-sdk/credential-provider-process": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.964.0.tgz",
|
||||||
"integrity": "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==",
|
"integrity": "sha512-HaTLKqj3jeZY88E/iBjsNJsXgmRTTT7TghqeRiF8FKb/7UY1xEvasBO0c1xqfOye8dsyt35nTfTTyIsd/CBfww==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/property-provider": "^4.2.7",
|
"@smithy/property-provider": "^4.2.7",
|
||||||
"@smithy/shared-ini-file-loader": "^4.4.2",
|
"@smithy/shared-ini-file-loader": "^4.4.2",
|
||||||
@@ -532,14 +533,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||||
"version": "3.958.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.958.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.964.0.tgz",
|
||||||
"integrity": "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==",
|
"integrity": "sha512-oR78TjSpjVf1IpPWQnGHEGqlnQs+K4f5nCxLK2P6JDPprXay6oknsoSiU4x2urav6VCyMPMC9KTCGjBoFKUIxQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sso": "3.958.0",
|
"@aws-sdk/client-sso": "3.964.0",
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/token-providers": "3.958.0",
|
"@aws-sdk/token-providers": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/property-provider": "^4.2.7",
|
"@smithy/property-provider": "^4.2.7",
|
||||||
"@smithy/shared-ini-file-loader": "^4.4.2",
|
"@smithy/shared-ini-file-loader": "^4.4.2",
|
||||||
@@ -551,13 +552,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||||
"version": "3.958.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.958.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.964.0.tgz",
|
||||||
"integrity": "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==",
|
"integrity": "sha512-07JQDmbjZjOt3nL/j1wTcvQqjmPkynQYftUV/ooZ+qTbmJXFbCBdal1VCElyeiu0AgBq9dfhw0rBBcbND1ZMlA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/nested-clients": "3.958.0",
|
"@aws-sdk/nested-clients": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/property-provider": "^4.2.7",
|
"@smithy/property-provider": "^4.2.7",
|
||||||
"@smithy/shared-ini-file-loader": "^4.4.2",
|
"@smithy/shared-ini-file-loader": "^4.4.2",
|
||||||
@@ -569,9 +570,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/lib-storage": {
|
"node_modules/@aws-sdk/lib-storage": {
|
||||||
"version": "3.962.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.962.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.964.0.tgz",
|
||||||
"integrity": "sha512-Ai5gWRQkzsUMQ6NPoZZoiLXoQ6/yPRcR4oracIVjyWcu48TfBpsRgbqY/5zNOM55ag1wPX9TtJJGOhK3TNk45g==",
|
"integrity": "sha512-ro6B04Q5TjPgIKdSWGJ+tj2ordVF1IfZJERwGpYkrwhboNEoXBXuzpfnh2LYBPvMmFJQ+8UXSFw1jkLLgxM+ig==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@smithy/abort-controller": "^4.2.7",
|
"@smithy/abort-controller": "^4.2.7",
|
||||||
@@ -586,7 +587,7 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.962.0"
|
"@aws-sdk/client-s3": "^3.964.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
|
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
|
||||||
@@ -623,15 +624,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/middleware-flexible-checksums": {
|
"node_modules/@aws-sdk/middleware-flexible-checksums": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.964.0.tgz",
|
||||||
"integrity": "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA==",
|
"integrity": "sha512-IA2kSKkwC/HHFF75nTR7s/nWt5CboB6vMgpLpvx40Cc01cMp+06Jr7U2/+DPPc8fkCagTytchY4gX9Hzn5ej8g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/crc32": "5.2.0",
|
"@aws-crypto/crc32": "5.2.0",
|
||||||
"@aws-crypto/crc32c": "5.2.0",
|
"@aws-crypto/crc32c": "5.2.0",
|
||||||
"@aws-crypto/util": "5.2.0",
|
"@aws-crypto/util": "5.2.0",
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/crc64-nvme": "3.957.0",
|
"@aws-sdk/crc64-nvme": "3.957.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/is-array-buffer": "^4.2.0",
|
"@smithy/is-array-buffer": "^4.2.0",
|
||||||
@@ -707,12 +708,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.964.0.tgz",
|
||||||
"integrity": "sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg==",
|
"integrity": "sha512-SeFcLo3tUdI3amzoIiArd9O0i7vAB0n5fgbQHBu137s3SbSLO5tPspE25rrUITwlc5HTbHMK6UzBq+3hITmImA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@aws-sdk/util-arn-parser": "3.957.0",
|
"@aws-sdk/util-arn-parser": "3.957.0",
|
||||||
"@smithy/core": "^3.20.0",
|
"@smithy/core": "^3.20.0",
|
||||||
@@ -746,12 +747,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/middleware-user-agent": {
|
"node_modules/@aws-sdk/middleware-user-agent": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.964.0.tgz",
|
||||||
"integrity": "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==",
|
"integrity": "sha512-/QyBl8WLNtqw3ucyAggumQXVCi8GRxaDGE1ElyYMmacfiwHl37S9y8JVW/QLL1lIEXGcsrhMUKV3pyFJFALA7w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@aws-sdk/util-endpoints": "3.957.0",
|
"@aws-sdk/util-endpoints": "3.957.0",
|
||||||
"@smithy/core": "^3.20.0",
|
"@smithy/core": "^3.20.0",
|
||||||
@@ -764,23 +765,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/nested-clients": {
|
"node_modules/@aws-sdk/nested-clients": {
|
||||||
"version": "3.958.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.958.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.964.0.tgz",
|
||||||
"integrity": "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==",
|
"integrity": "sha512-ql+ftRwjyZkZeG3qbrRJFVmNR0id83WEUqhFVjvrQMWspNApBhz0Ar4YVSn7Uv0QaKkaR7ALPtmdMzFr3/E4bQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/sha256-browser": "5.2.0",
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
"@aws-crypto/sha256-js": "5.2.0",
|
"@aws-crypto/sha256-js": "5.2.0",
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/middleware-host-header": "3.957.0",
|
"@aws-sdk/middleware-host-header": "3.957.0",
|
||||||
"@aws-sdk/middleware-logger": "3.957.0",
|
"@aws-sdk/middleware-logger": "3.957.0",
|
||||||
"@aws-sdk/middleware-recursion-detection": "3.957.0",
|
"@aws-sdk/middleware-recursion-detection": "3.957.0",
|
||||||
"@aws-sdk/middleware-user-agent": "3.957.0",
|
"@aws-sdk/middleware-user-agent": "3.964.0",
|
||||||
"@aws-sdk/region-config-resolver": "3.957.0",
|
"@aws-sdk/region-config-resolver": "3.957.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@aws-sdk/util-endpoints": "3.957.0",
|
"@aws-sdk/util-endpoints": "3.957.0",
|
||||||
"@aws-sdk/util-user-agent-browser": "3.957.0",
|
"@aws-sdk/util-user-agent-browser": "3.957.0",
|
||||||
"@aws-sdk/util-user-agent-node": "3.957.0",
|
"@aws-sdk/util-user-agent-node": "3.964.0",
|
||||||
"@smithy/config-resolver": "^4.4.5",
|
"@smithy/config-resolver": "^4.4.5",
|
||||||
"@smithy/core": "^3.20.0",
|
"@smithy/core": "^3.20.0",
|
||||||
"@smithy/fetch-http-handler": "^5.3.8",
|
"@smithy/fetch-http-handler": "^5.3.8",
|
||||||
@@ -829,12 +830,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/s3-request-presigner": {
|
"node_modules/@aws-sdk/s3-request-presigner": {
|
||||||
"version": "3.962.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.962.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.964.0.tgz",
|
||||||
"integrity": "sha512-tyxsGfLY4NSohLrJsFGXbE3j8jguWK+hdGaUQSD1gJPvmC0B82qOyJ7WBIJLWgTabU3fiF/I9EGXjzR2rKr8jQ==",
|
"integrity": "sha512-gKKdIZGYV8Ohm3X8j3y6Xr2ua1oD/Wsa3N7hYro3HqcnuGvl1h+mdw0IqUU+5yEzcoM5ItLJnH+6Q8Xz+Wv9gw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/signature-v4-multi-region": "3.957.0",
|
"@aws-sdk/signature-v4-multi-region": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@aws-sdk/util-format-url": "3.957.0",
|
"@aws-sdk/util-format-url": "3.957.0",
|
||||||
"@smithy/middleware-endpoint": "^4.4.1",
|
"@smithy/middleware-endpoint": "^4.4.1",
|
||||||
@@ -848,12 +849,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.964.0.tgz",
|
||||||
"integrity": "sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==",
|
"integrity": "sha512-ASQmO9EB2ukSTGpO7B2ZceSbNVivCLqWh89o/JJtcIdGpOu8p9XHpeK3hiUz2OQo2Igw03/n8s+DNvP+N9krpw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/middleware-sdk-s3": "3.957.0",
|
"@aws-sdk/middleware-sdk-s3": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/protocol-http": "^5.3.7",
|
"@smithy/protocol-http": "^5.3.7",
|
||||||
"@smithy/signature-v4": "^5.3.7",
|
"@smithy/signature-v4": "^5.3.7",
|
||||||
@@ -865,13 +866,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/token-providers": {
|
"node_modules/@aws-sdk/token-providers": {
|
||||||
"version": "3.958.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.958.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.964.0.tgz",
|
||||||
"integrity": "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==",
|
"integrity": "sha512-UqouLQbYepZnMFJGB/DVpA5GhF9uT98vNWSMz9PVbhgEPUKa73FECRT6YFZvZOh8kA+0JiENrnmS6d93I70ykQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/core": "3.957.0",
|
"@aws-sdk/core": "3.964.0",
|
||||||
"@aws-sdk/nested-clients": "3.958.0",
|
"@aws-sdk/nested-clients": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/property-provider": "^4.2.7",
|
"@smithy/property-provider": "^4.2.7",
|
||||||
"@smithy/shared-ini-file-loader": "^4.4.2",
|
"@smithy/shared-ini-file-loader": "^4.4.2",
|
||||||
@@ -963,12 +964,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@aws-sdk/util-user-agent-node": {
|
"node_modules/@aws-sdk/util-user-agent-node": {
|
||||||
"version": "3.957.0",
|
"version": "3.964.0",
|
||||||
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.957.0.tgz",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.964.0.tgz",
|
||||||
"integrity": "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==",
|
"integrity": "sha512-jgob8Z/bZIh1dwEgLqE12q+aCf0ieLy7anT8bWpqMijMJqsnrPBToa7smSykfom9YHrdOgrQhXswMpE75dzLRw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/middleware-user-agent": "3.957.0",
|
"@aws-sdk/middleware-user-agent": "3.964.0",
|
||||||
"@aws-sdk/types": "3.957.0",
|
"@aws-sdk/types": "3.957.0",
|
||||||
"@smithy/node-config-provider": "^4.3.7",
|
"@smithy/node-config-provider": "^4.3.7",
|
||||||
"@smithy/types": "^4.11.0",
|
"@smithy/types": "^4.11.0",
|
||||||
@@ -5399,30 +5400,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/encoding": {
|
|
||||||
"version": "0.1.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
|
||||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"iconv-lite": "^0.6.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/encoding/node_modules/iconv-lite": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -6883,12 +6860,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
@@ -9661,6 +9638,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-addr/node_modules/ipaddr.js": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
"i18next": "25.3.2",
|
"i18next": "25.3.2",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
|
"ipaddr.js": "^2.3.0",
|
||||||
"joi": "^17.9.1",
|
"joi": "^17.9.1",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
|||||||
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||||
|
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
||||||
|
app.use('/api/invite', require('./src/routes/acceptInvite'));
|
||||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||||
|
|||||||
@@ -57,32 +57,59 @@ async function adminAuth(req, res, next) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if admin still exists and is active
|
// Check if admin still exists and is active, including role info
|
||||||
const admin = await db('admin_users')
|
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
|
||||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
let admin;
|
||||||
.first();
|
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) {
|
if (!admin) {
|
||||||
return res.status(401).json({ error: 'Invalid token' });
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if password was changed after token was issued
|
// Check if password was changed after token was issued
|
||||||
if (admin.password_changed_at) {
|
if (admin.password_changed_at) {
|
||||||
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
|
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
|
||||||
if (decoded.iat < passwordChangedTime) {
|
if (decoded.iat < passwordChangedTime) {
|
||||||
logger.warn('Token used after password change', { userId: decoded.id });
|
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',
|
error: 'Token invalid due to password change',
|
||||||
code: 'PASSWORD_CHANGED'
|
code: 'PASSWORD_CHANGED'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user info to request
|
// Add user info to request (enhanced with role)
|
||||||
req.admin = {
|
req.admin = {
|
||||||
id: admin.id,
|
id: admin.id,
|
||||||
username: admin.username,
|
username: admin.username,
|
||||||
email: admin.email
|
email: admin.email,
|
||||||
|
roleId: admin.role_id,
|
||||||
|
roleName: admin.role_name
|
||||||
};
|
};
|
||||||
req.token = token; // Store token for potential revocation
|
req.token = token; // Store token for potential revocation
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -4,12 +4,13 @@ const fs = require('fs').promises;
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all archived events
|
// Get all archived events
|
||||||
router.get('/', adminAuth, async (req, res) => {
|
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page) || 1;
|
const page = parseInt(req.query.page) || 1;
|
||||||
const limit = parseInt(req.query.limit) || 20;
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
@@ -81,7 +82,7 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get single archive details
|
// Get single archive details
|
||||||
router.get('/:id', adminAuth, async (req, res) => {
|
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const archive = await db('events')
|
const archive = await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
@@ -137,7 +138,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Restore archive
|
// Restore archive
|
||||||
router.post('/:id/restore', adminAuth, async (req, res) => {
|
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const archive = await db('events')
|
const archive = await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
@@ -300,7 +301,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download archive
|
// Download archive
|
||||||
router.get('/:id/download', adminAuth, async (req, res) => {
|
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const archive = await db('events')
|
const archive = await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
@@ -349,7 +350,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete archive permanently
|
// Delete archive permanently
|
||||||
router.delete('/:id', adminAuth, async (req, res) => {
|
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const archive = await db('events')
|
const archive = await db('events')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
@@ -12,7 +13,7 @@ const S3StorageAdapter = require('../services/storage/s3Storage');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get backup configuration
|
// Get backup configuration
|
||||||
router.get('/config', adminAuth, async (req, res) => {
|
router.get('/config', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await db('app_settings')
|
const settings = await db('app_settings')
|
||||||
.where('setting_type', 'backup')
|
.where('setting_type', 'backup')
|
||||||
@@ -35,7 +36,7 @@ router.get('/config', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update backup configuration
|
// Update backup configuration
|
||||||
router.put('/config', adminAuth, async (req, res) => {
|
router.put('/config', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ router.put('/config', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get backup status and history
|
// Get backup status and history
|
||||||
router.get('/status', adminAuth, async (req, res) => {
|
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = parseInt(req.query.limit) || 10;
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
const status = await getBackupStatus(limit);
|
const status = await getBackupStatus(limit);
|
||||||
@@ -110,7 +111,7 @@ router.get('/status', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Trigger manual backup
|
// Trigger manual backup
|
||||||
router.post('/run', adminAuth, async (req, res) => {
|
router.post('/run', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Check if backup is already running
|
// Check if backup is already running
|
||||||
const status = await getBackupStatus();
|
const status = await getBackupStatus();
|
||||||
@@ -131,7 +132,7 @@ router.post('/run', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get backup run details
|
// Get backup run details
|
||||||
router.get('/runs/:id', adminAuth, async (req, res) => {
|
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -160,7 +161,7 @@ router.get('/runs/:id', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get file states (for debugging/monitoring)
|
// Get file states (for debugging/monitoring)
|
||||||
router.get('/files', adminAuth, async (req, res) => {
|
router.get('/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { page = 1, limit = 50, search = '' } = req.query;
|
const { page = 1, limit = 50, search = '' } = req.query;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
@@ -195,7 +196,7 @@ router.get('/files', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Clean up old backup runs
|
// Clean up old backup runs
|
||||||
router.delete('/cleanup', adminAuth, async (req, res) => {
|
router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { days = 30 } = req.body;
|
const { days = 30 } = req.body;
|
||||||
|
|
||||||
@@ -209,7 +210,7 @@ router.delete('/cleanup', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test backup destination connectivity
|
// Test backup destination connectivity
|
||||||
router.post('/test-connection', adminAuth, async (req, res) => {
|
router.post('/test-connection', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { destination_type, ...config } = req.body;
|
const { destination_type, ...config } = req.body;
|
||||||
|
|
||||||
@@ -334,7 +335,7 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get backup manifest for a specific backup run
|
// Get backup manifest for a specific backup run
|
||||||
router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { backupRunId } = req.params;
|
const { backupRunId } = req.params;
|
||||||
const result = await getBackupManifest(backupRunId);
|
const result = await getBackupManifest(backupRunId);
|
||||||
@@ -351,7 +352,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Validate a backup manifest
|
// Validate a backup manifest
|
||||||
router.post('/manifest/validate', adminAuth, async (req, res) => {
|
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { manifestPath } = req.body;
|
const { manifestPath } = req.body;
|
||||||
|
|
||||||
@@ -373,7 +374,7 @@ router.post('/manifest/validate', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download backup manifest
|
// Download backup manifest
|
||||||
router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
router.get('/manifest/:backupRunId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { backupRunId } = req.params;
|
const { backupRunId } = req.params;
|
||||||
const { format = 'json' } = req.query;
|
const { format = 'json' } = req.query;
|
||||||
@@ -404,7 +405,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get manifest for specific backup
|
// Get manifest for specific backup
|
||||||
router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
router.get('/manifests/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { backupId } = req.params;
|
const { backupId } = req.params;
|
||||||
const result = await getBackupManifest(backupId);
|
const result = await getBackupManifest(backupId);
|
||||||
@@ -421,7 +422,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download manifest file
|
// Download manifest file
|
||||||
router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { backupId } = req.params;
|
const { backupId } = req.params;
|
||||||
const { format = 'json' } = req.query;
|
const { format = 'json' } = req.query;
|
||||||
@@ -452,7 +453,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Validate a manifest
|
// Validate a manifest
|
||||||
router.post('/manifests/validate', adminAuth, async (req, res) => {
|
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { manifestPath, manifestData } = req.body;
|
const { manifestPath, manifestData } = req.body;
|
||||||
|
|
||||||
@@ -481,7 +482,7 @@ router.post('/manifests/validate', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List S3 buckets
|
// List S3 buckets
|
||||||
router.get('/s3/buckets', adminAuth, async (req, res) => {
|
router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const config = await getBackupConfig();
|
const config = await getBackupConfig();
|
||||||
|
|
||||||
@@ -513,7 +514,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List files in S3 backup location
|
// List files in S3 backup location
|
||||||
router.get('/s3/files', adminAuth, async (req, res) => {
|
router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
|
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
|
||||||
const config = await getBackupConfig();
|
const config = await getBackupConfig();
|
||||||
@@ -550,7 +551,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Clean up old S3 backups
|
// Clean up old S3 backups
|
||||||
router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { retentionDays = 30, dryRun = false } = req.body;
|
const { retentionDays = 30, dryRun = false } = req.body;
|
||||||
const config = await getBackupConfig();
|
const config = await getBackupConfig();
|
||||||
@@ -612,7 +613,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test S3 upload functionality
|
// Test S3 upload functionality
|
||||||
router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const config = await getBackupConfig();
|
const config = await getBackupConfig();
|
||||||
|
|
||||||
@@ -664,7 +665,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download entire backup
|
// Download entire backup
|
||||||
router.get('/download/:backupId', adminAuth, async (req, res) => {
|
router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { backupId } = req.params;
|
const { backupId } = req.params;
|
||||||
|
|
||||||
@@ -752,7 +753,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get current file checksums
|
// Get current file checksums
|
||||||
router.get('/checksums', adminAuth, async (req, res) => {
|
router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { path: targetPath = '', recursive = true } = req.query;
|
const { path: targetPath = '', recursive = true } = req.query;
|
||||||
const checksums = {};
|
const checksums = {};
|
||||||
@@ -821,7 +822,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Estimate backup size before running
|
// Estimate backup size before running
|
||||||
router.post('/estimate', adminAuth, async (req, res) => {
|
router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { includeArchived = true } = req.body;
|
const { includeArchived = true } = req.body;
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ const express = require('express');
|
|||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all CMS pages
|
// Get all CMS pages
|
||||||
router.get('/pages', adminAuth, async (req, res) => {
|
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
|
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
|
||||||
res.json(pages);
|
res.json(pages);
|
||||||
@@ -16,7 +17,7 @@ router.get('/pages', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get a single CMS page
|
// Get a single CMS page
|
||||||
router.get('/pages/:slug', adminAuth, async (req, res) => {
|
router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { slug } = req.params;
|
const { slug } = req.params;
|
||||||
const page = await db('cms_pages').where('slug', slug).first();
|
const page = await db('cms_pages').where('slug', slug).first();
|
||||||
@@ -33,7 +34,7 @@ router.get('/pages/:slug', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update a CMS page
|
// Update a CMS page
|
||||||
router.put('/pages/:slug', adminAuth, [
|
router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
|
||||||
body('title_en').optional().isString(),
|
body('title_en').optional().isString(),
|
||||||
body('title_de').optional().isString(),
|
body('title_de').optional().isString(),
|
||||||
body('content_en').optional().isString(),
|
body('content_en').optional().isString(),
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ const { body, validationResult } = require('express-validator');
|
|||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all global categories
|
// Get all global categories
|
||||||
router.get('/global', adminAuth, async (req, res) => {
|
router.get('/global', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const categories = await db('photo_categories')
|
const categories = await db('photo_categories')
|
||||||
.where('is_global', formatBoolean(true))
|
.where('is_global', formatBoolean(true))
|
||||||
@@ -20,7 +21,7 @@ router.get('/global', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get categories for a specific event (global + event-specific)
|
// Get categories for a specific event (global + event-specific)
|
||||||
router.get('/event/:eventId', adminAuth, async (req, res) => {
|
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a new category
|
// Create a new category
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||||
body('name').notEmpty().withMessage('Category name is required'),
|
body('name').notEmpty().withMessage('Category name is required'),
|
||||||
body('slug').optional(),
|
body('slug').optional(),
|
||||||
body('is_global').optional().isBoolean(),
|
body('is_global').optional().isBoolean(),
|
||||||
@@ -104,7 +105,7 @@ router.post('/', adminAuth, [
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update a category
|
// Update a category
|
||||||
router.put('/:id', adminAuth, [
|
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||||
body('name').notEmpty().withMessage('Category name is required')
|
body('name').notEmpty().withMessage('Category name is required')
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -149,7 +150,7 @@ router.put('/:id', adminAuth, [
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete a category
|
// Delete a category
|
||||||
router.delete('/:id', adminAuth, async (req, res) => {
|
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const router = express.Router();
|
|||||||
const { body, param, validationResult } = require('express-validator');
|
const { body, param, validationResult } = require('express-validator');
|
||||||
const { db, withRetry } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
|
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
|
||||||
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
|
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_temp
|
|||||||
* GET /admin/css-templates
|
* GET /admin/css-templates
|
||||||
* Get all CSS templates
|
* Get all CSS templates
|
||||||
*/
|
*/
|
||||||
router.get('/', adminAuth, async (req, res) => {
|
router.get('/', adminAuth, requirePermission('branding.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const templates = await withRetry(() =>
|
const templates = await withRetry(() =>
|
||||||
db('css_templates').orderBy('slot_number')
|
db('css_templates').orderBy('slot_number')
|
||||||
@@ -31,7 +32,7 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
* GET /admin/css-templates/enabled
|
* GET /admin/css-templates/enabled
|
||||||
* Get only enabled templates (for event form dropdown)
|
* Get only enabled templates (for event form dropdown)
|
||||||
*/
|
*/
|
||||||
router.get('/enabled', adminAuth, async (req, res) => {
|
router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const templates = await withRetry(() =>
|
const templates = await withRetry(() =>
|
||||||
db('css_templates')
|
db('css_templates')
|
||||||
@@ -50,7 +51,7 @@ router.get('/enabled', adminAuth, async (req, res) => {
|
|||||||
* GET /admin/css-templates/:slotNumber
|
* GET /admin/css-templates/:slotNumber
|
||||||
* Get a specific template by slot number
|
* Get a specific template by slot number
|
||||||
*/
|
*/
|
||||||
router.get('/:slotNumber', adminAuth, [
|
router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
|
||||||
param('slotNumber').isInt({ min: 1, max: 3 })
|
param('slotNumber').isInt({ min: 1, max: 3 })
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -81,7 +82,7 @@ router.get('/:slotNumber', adminAuth, [
|
|||||||
* PUT /admin/css-templates/:slotNumber
|
* PUT /admin/css-templates/:slotNumber
|
||||||
* Update a template
|
* Update a template
|
||||||
*/
|
*/
|
||||||
router.put('/:slotNumber', adminAuth, [
|
router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
|
||||||
param('slotNumber').isInt({ min: 1, max: 3 }),
|
param('slotNumber').isInt({ min: 1, max: 3 }),
|
||||||
body('name').optional().isString().isLength({ max: 50 }),
|
body('name').optional().isString().isLength({ max: 50 }),
|
||||||
body('css_content').optional().isString(),
|
body('css_content').optional().isString(),
|
||||||
@@ -158,7 +159,7 @@ router.put('/:slotNumber', adminAuth, [
|
|||||||
* POST /admin/css-templates/:slotNumber/reset
|
* POST /admin/css-templates/:slotNumber/reset
|
||||||
* Reset template to default (only for slot 1)
|
* Reset template to default (only for slot 1)
|
||||||
*/
|
*/
|
||||||
router.post('/:slotNumber/reset', adminAuth, [
|
router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'), [
|
||||||
param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
|
param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get dashboard statistics
|
// Get dashboard statistics
|
||||||
router.get('/stats', adminAuth, async (req, res) => {
|
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get active events count
|
// Get active events count
|
||||||
const activeEvents = await db('events')
|
const activeEvents = await db('events')
|
||||||
@@ -106,7 +107,7 @@ router.get('/stats', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get recent activity
|
// Get recent activity
|
||||||
router.get('/activity', adminAuth, async (req, res) => {
|
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = parseInt(req.query.limit) || 10;
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ router.get('/activity', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get system health status
|
// Get system health status
|
||||||
router.get('/health', adminAuth, async (req, res) => {
|
router.get('/health', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|
||||||
@@ -216,7 +217,7 @@ router.get('/health', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get analytics data for charts
|
// Get analytics data for charts
|
||||||
router.get('/analytics', adminAuth, async (req, res) => {
|
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const days = sanitizeDays(req.query.days || 7);
|
const days = sanitizeDays(req.query.days || 7);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { databaseBackupService } = require('../services/databaseBackup');
|
const { databaseBackupService } = require('../services/databaseBackup');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -11,7 +12,7 @@ router.use(adminAuth);
|
|||||||
/**
|
/**
|
||||||
* Get database backup status and configuration
|
* Get database backup status and configuration
|
||||||
*/
|
*/
|
||||||
router.get('/status', async (req, res) => {
|
router.get('/status', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get configuration
|
// Get configuration
|
||||||
const config = await databaseBackupService.getBackupConfig();
|
const config = await databaseBackupService.getBackupConfig();
|
||||||
@@ -45,7 +46,7 @@ router.get('/status', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Update database backup configuration
|
* Update database backup configuration
|
||||||
*/
|
*/
|
||||||
router.put('/config', async (req, res) => {
|
router.put('/config', requirePermission('backup.create'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const allowedSettings = [
|
const allowedSettings = [
|
||||||
'database_backup_enabled',
|
'database_backup_enabled',
|
||||||
@@ -108,7 +109,7 @@ router.put('/config', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Trigger manual database backup
|
* Trigger manual database backup
|
||||||
*/
|
*/
|
||||||
router.post('/backup', async (req, res) => {
|
router.post('/backup', requirePermission('backup.create'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (databaseBackupService.isRunning) {
|
if (databaseBackupService.isRunning) {
|
||||||
return res.status(409).json({ error: 'Backup already in progress' });
|
return res.status(409).json({ error: 'Backup already in progress' });
|
||||||
@@ -134,7 +135,7 @@ router.post('/backup', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Get current backup progress
|
* Get current backup progress
|
||||||
*/
|
*/
|
||||||
router.get('/progress', async (req, res) => {
|
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const progress = databaseBackupService.getProgress();
|
const progress = databaseBackupService.getProgress();
|
||||||
|
|
||||||
@@ -151,7 +152,7 @@ router.get('/progress', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Get backup history with pagination
|
* Get backup history with pagination
|
||||||
*/
|
*/
|
||||||
router.get('/history', async (req, res) => {
|
router.get('/history', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page) || 1;
|
const page = parseInt(req.query.page) || 1;
|
||||||
const limit = parseInt(req.query.limit) || 20;
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
@@ -183,7 +184,7 @@ router.get('/history', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Delete old backup files
|
* Delete old backup files
|
||||||
*/
|
*/
|
||||||
router.delete('/cleanup', async (req, res) => {
|
router.delete('/cleanup', requirePermission('backup.delete'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { retentionDays = 30 } = req.body;
|
const { retentionDays = 30 } = req.body;
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ router.delete('/cleanup', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Test database backup configuration
|
* Test database backup configuration
|
||||||
*/
|
*/
|
||||||
router.post('/test', async (req, res) => {
|
router.post('/test', requirePermission('backup.create'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const config = await databaseBackupService.getBackupConfig();
|
const config = await databaseBackupService.getBackupConfig();
|
||||||
|
|
||||||
@@ -255,7 +256,7 @@ router.post('/test', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Get table checksums
|
* Get table checksums
|
||||||
*/
|
*/
|
||||||
router.get('/checksums', async (req, res) => {
|
router.get('/checksums', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const checksums = await databaseBackupService.getTableChecksums();
|
const checksums = await databaseBackupService.getTableChecksums();
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ const nodemailer = require('nodemailer');
|
|||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get email configuration
|
// Get email configuration
|
||||||
router.get('/config', adminAuth, async (req, res) => {
|
router.get('/config', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const config = await db('email_configs').first();
|
const config = await db('email_configs').first();
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ router.get('/config', adminAuth, async (req, res) => {
|
|||||||
// Update email configuration
|
// Update email configuration
|
||||||
router.post('/config', [
|
router.post('/config', [
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('email.edit'),
|
||||||
body('smtp_host').notEmpty().withMessage('SMTP host is required'),
|
body('smtp_host').notEmpty().withMessage('SMTP host is required'),
|
||||||
body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'),
|
body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'),
|
||||||
body('from_email').isEmail().withMessage('Invalid from email address')
|
body('from_email').isEmail().withMessage('Invalid from email address')
|
||||||
@@ -100,7 +102,7 @@ router.post('/config', [
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Test email configuration
|
// Test email configuration
|
||||||
router.post('/test', adminAuth, async (req, res) => {
|
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { test_email } = req.body;
|
const { test_email } = req.body;
|
||||||
|
|
||||||
@@ -236,7 +238,7 @@ router.post('/test', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get email templates
|
// Get email templates
|
||||||
router.get('/templates', adminAuth, async (req, res) => {
|
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const templates = await db('email_templates')
|
const templates = await db('email_templates')
|
||||||
.select('*')
|
.select('*')
|
||||||
@@ -290,7 +292,7 @@ router.get('/templates', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get single template
|
// Get single template
|
||||||
router.get('/templates/:key', adminAuth, async (req, res) => {
|
router.get('/templates/:key', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const template = await db('email_templates')
|
const template = await db('email_templates')
|
||||||
.where('template_key', req.params.key)
|
.where('template_key', req.params.key)
|
||||||
@@ -346,6 +348,7 @@ router.get('/templates/:key', adminAuth, async (req, res) => {
|
|||||||
// Update email template
|
// Update email template
|
||||||
router.put('/templates/:key', [
|
router.put('/templates/:key', [
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('email.edit'),
|
||||||
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
|
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
|
||||||
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
|
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
|
||||||
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
|
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
|
||||||
@@ -424,7 +427,7 @@ router.put('/templates/:key', [
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Preview email template
|
// Preview email template
|
||||||
router.post('/templates/:key/preview', adminAuth, async (req, res) => {
|
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const template = await db('email_templates')
|
const template = await db('email_templates')
|
||||||
.where('template_key', req.params.key)
|
.where('template_key', req.params.key)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const eventRenameService = require('../services/eventRenameService');
|
const eventRenameService = require('../services/eventRenameService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ const router = express.Router();
|
|||||||
* POST /api/admin/events/:eventId/rename
|
* POST /api/admin/events/:eventId/rename
|
||||||
* Rename an event
|
* Rename an event
|
||||||
*/
|
*/
|
||||||
router.post('/:eventId/rename', adminAuth, [
|
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||||
body('newEventName')
|
body('newEventName')
|
||||||
.trim()
|
.trim()
|
||||||
.isLength({ min: 3, max: 100 })
|
.isLength({ min: 3, max: 100 })
|
||||||
@@ -58,7 +59,7 @@ router.post('/:eventId/rename', adminAuth, [
|
|||||||
* POST /api/admin/events/:eventId/validate-rename
|
* POST /api/admin/events/:eventId/validate-rename
|
||||||
* Validate a potential rename without executing it
|
* Validate a potential rename without executing it
|
||||||
*/
|
*/
|
||||||
router.post('/:eventId/validate-rename', adminAuth, [
|
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
|
||||||
body('newEventName')
|
body('newEventName')
|
||||||
.trim()
|
.trim()
|
||||||
.isLength({ min: 3, max: 100 })
|
.isLength({ min: 3, max: 100 })
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
|
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
|
|
||||||
// Enhanced event creation with password validation
|
// Enhanced event creation with password validation
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||||
body('event_name').notEmpty().trim(),
|
body('event_name').notEmpty().trim(),
|
||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const { body, query, validationResult } = require('express-validator');
|
|||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -102,7 +103,7 @@ const hasCustomerContactColumns = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||||
body('event_name').notEmpty().trim(),
|
body('event_name').notEmpty().trim(),
|
||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
@@ -296,6 +297,7 @@ router.post('/', adminAuth, [
|
|||||||
share_token: shareToken,
|
share_token: shareToken,
|
||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
|
created_by: req.admin.id,
|
||||||
allow_user_uploads,
|
allow_user_uploads,
|
||||||
upload_category_id,
|
upload_category_id,
|
||||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||||
@@ -375,7 +377,7 @@ router.post('/', adminAuth, [
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get all events with pagination and filters
|
// Get all events with pagination and filters
|
||||||
router.get('/', adminAuth, async (req, res) => {
|
router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page) || 1;
|
const page = parseInt(req.query.page) || 1;
|
||||||
const limit = parseInt(req.query.limit) || 20;
|
const limit = parseInt(req.query.limit) || 20;
|
||||||
@@ -387,7 +389,12 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Build query
|
// Build query
|
||||||
let query = db('events');
|
let query = db('events');
|
||||||
|
|
||||||
|
// Editor role can only see their own events
|
||||||
|
if (req.admin.roleName === 'editor') {
|
||||||
|
query = query.where('created_by', req.admin.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
if (search) {
|
if (search) {
|
||||||
const escapedSearch = escapeLikePattern(search);
|
const escapedSearch = escapeLikePattern(search);
|
||||||
@@ -465,13 +472,18 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get single event details
|
// Get single event details
|
||||||
router.get('/:id', adminAuth, async (req, res) => {
|
router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
const event = await db('events')
|
let query = db('events').where('id', id);
|
||||||
.where('id', id)
|
|
||||||
.first();
|
// Editor role can only see their own events
|
||||||
|
if (req.admin.roleName === 'editor') {
|
||||||
|
query = query.where('created_by', req.admin.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await query.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
@@ -525,7 +537,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update event
|
// Update event
|
||||||
router.put('/:id', adminAuth, [
|
router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||||
body('event_name').optional().trim().notEmpty(),
|
body('event_name').optional().trim().notEmpty(),
|
||||||
body('admin_email').optional().isEmail(),
|
body('admin_email').optional().isEmail(),
|
||||||
body('is_active').optional().isBoolean(),
|
body('is_active').optional().isBoolean(),
|
||||||
@@ -659,7 +671,12 @@ router.put('/:id', adminAuth, [
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Check if event exists
|
// Check if event exists
|
||||||
const event = await db('events').where('id', id).first();
|
let eventQuery = db('events').where('id', id);
|
||||||
|
// Editor role can only edit their own events
|
||||||
|
if (req.admin.roleName === 'editor') {
|
||||||
|
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||||
|
}
|
||||||
|
const event = await eventQuery.first();
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
@@ -696,7 +713,7 @@ router.put('/:id', adminAuth, [
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete event
|
// Delete event
|
||||||
router.delete('/:id', adminAuth, async (req, res) => {
|
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -777,11 +794,16 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Toggle event status
|
// Toggle event status
|
||||||
router.post('/:id/toggle-status', adminAuth, async (req, res) => {
|
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
const event = await db('events').where('id', id).first();
|
let eventQuery = db('events').where('id', id);
|
||||||
|
// Editor role can only edit their own events
|
||||||
|
if (req.admin.roleName === 'editor') {
|
||||||
|
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||||
|
}
|
||||||
|
const event = await eventQuery.first();
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
@@ -812,12 +834,17 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reset event password
|
// Reset event password
|
||||||
router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { sendEmail = true } = req.body;
|
const { sendEmail = true } = req.body;
|
||||||
|
|
||||||
const event = await db('events').where('id', id).first();
|
let eventQuery = db('events').where('id', id);
|
||||||
|
// Editor role can only edit their own events
|
||||||
|
if (req.admin.roleName === 'editor') {
|
||||||
|
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||||
|
}
|
||||||
|
const event = await eventQuery.first();
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
@@ -874,15 +901,18 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Resend creation email
|
// Resend creation email
|
||||||
router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
// Get event details
|
// Get event details
|
||||||
const event = await db('events')
|
let eventQuery = db('events').where('id', id);
|
||||||
.where('id', id)
|
// Editor role can only edit their own events
|
||||||
.first();
|
if (req.admin.roleName === 'editor') {
|
||||||
|
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||||
|
}
|
||||||
|
const event = await eventQuery.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
@@ -954,7 +984,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Archive event
|
// Archive event
|
||||||
router.post('/:id/archive', adminAuth, async (req, res) => {
|
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -985,7 +1015,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Bulk archive events
|
// Bulk archive events
|
||||||
router.post('/bulk-archive', adminAuth, [
|
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||||
body('eventIds').isArray().withMessage('eventIds must be an array'),
|
body('eventIds').isArray().withMessage('eventIds must be an array'),
|
||||||
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// GET /api/admin/external-media/list?path=relative/dir
|
// GET /api/admin/external-media/list?path=relative/dir
|
||||||
router.get('/list', adminAuth, async (req, res) => {
|
router.get('/list', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const relPath = (req.query.path || '').replace(/^\/+/, '');
|
const relPath = (req.query.path || '').replace(/^\/+/, '');
|
||||||
const result = await list(relPath);
|
const result = await list(relPath);
|
||||||
@@ -45,7 +46,7 @@ async function walkDir(dir, baseDir) {
|
|||||||
|
|
||||||
// POST /api/admin/events/:id/import-external
|
// POST /api/admin/events/:id/import-external
|
||||||
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
|
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
|
||||||
router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const eventId = parseInt(req.params.id);
|
const eventId = parseInt(req.params.id);
|
||||||
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
|
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const feedbackService = require('../services/feedbackService');
|
const feedbackService = require('../services/feedbackService');
|
||||||
const feedbackModeration = require('../services/feedbackModeration');
|
const feedbackModeration = require('../services/feedbackModeration');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
@@ -13,8 +14,9 @@ const {
|
|||||||
} = require('../utils/feedbackValidation');
|
} = require('../utils/feedbackValidation');
|
||||||
|
|
||||||
// Get event feedback settings
|
// Get event feedback settings
|
||||||
router.get('/events/:eventId/feedback-settings',
|
router.get('/events/:eventId/feedback-settings',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('events.view'),
|
||||||
validateEventId,
|
validateEventId,
|
||||||
checkValidation,
|
checkValidation,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -39,6 +41,7 @@ router.get('/events/:eventId/feedback-settings',
|
|||||||
// Update event feedback settings
|
// Update event feedback settings
|
||||||
router.put('/events/:eventId/feedback-settings',
|
router.put('/events/:eventId/feedback-settings',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('events.edit'),
|
||||||
validateEventId,
|
validateEventId,
|
||||||
validateFeedbackSettings,
|
validateFeedbackSettings,
|
||||||
checkValidation,
|
checkValidation,
|
||||||
@@ -75,6 +78,7 @@ router.put('/events/:eventId/feedback-settings',
|
|||||||
// Get feedback for an event (with filters)
|
// Get feedback for an event (with filters)
|
||||||
router.get('/events/:eventId/feedback',
|
router.get('/events/:eventId/feedback',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('events.view'),
|
||||||
validateEventId,
|
validateEventId,
|
||||||
checkValidation,
|
checkValidation,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -159,6 +163,7 @@ router.get('/events/:eventId/feedback',
|
|||||||
// Moderate feedback (approve/hide/reject)
|
// Moderate feedback (approve/hide/reject)
|
||||||
router.put('/feedback/:feedbackId/:action',
|
router.put('/feedback/:feedbackId/:action',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('events.edit'),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { feedbackId, action } = req.params;
|
const { feedbackId, action } = req.params;
|
||||||
@@ -180,6 +185,7 @@ router.put('/feedback/:feedbackId/:action',
|
|||||||
// Delete feedback
|
// Delete feedback
|
||||||
router.delete('/feedback/:feedbackId',
|
router.delete('/feedback/:feedbackId',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('events.delete'),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { feedbackId } = req.params;
|
const { feedbackId } = req.params;
|
||||||
@@ -197,6 +203,7 @@ router.delete('/feedback/:feedbackId',
|
|||||||
// Get feedback analytics for an event
|
// Get feedback analytics for an event
|
||||||
router.get('/events/:eventId/feedback-analytics',
|
router.get('/events/:eventId/feedback-analytics',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('events.view'),
|
||||||
validateEventId,
|
validateEventId,
|
||||||
checkValidation,
|
checkValidation,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -296,6 +303,7 @@ router.get('/events/:eventId/feedback-analytics',
|
|||||||
// Export feedback data
|
// Export feedback data
|
||||||
router.get('/events/:eventId/feedback/export',
|
router.get('/events/:eventId/feedback/export',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('events.view'),
|
||||||
validateEventId,
|
validateEventId,
|
||||||
checkValidation,
|
checkValidation,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -324,6 +332,7 @@ router.get('/events/:eventId/feedback/export',
|
|||||||
// Get pending moderation items (across all events)
|
// Get pending moderation items (across all events)
|
||||||
router.get('/feedback/pending-moderation',
|
router.get('/feedback/pending-moderation',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('events.view'),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pending = await feedbackService.getPendingModeration();
|
const pending = await feedbackService.getPendingModeration();
|
||||||
@@ -338,6 +347,7 @@ router.get('/feedback/pending-moderation',
|
|||||||
// Word filter management
|
// Word filter management
|
||||||
router.get('/word-filters',
|
router.get('/word-filters',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('settings.view'),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const filters = await feedbackModeration.getAllWordFilters();
|
const filters = await feedbackModeration.getAllWordFilters();
|
||||||
@@ -351,6 +361,7 @@ router.get('/word-filters',
|
|||||||
|
|
||||||
router.post('/word-filters',
|
router.post('/word-filters',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('settings.edit'),
|
||||||
validateWordFilter,
|
validateWordFilter,
|
||||||
checkValidation,
|
checkValidation,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -378,6 +389,7 @@ router.post('/word-filters',
|
|||||||
|
|
||||||
router.put('/word-filters/:id',
|
router.put('/word-filters/:id',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('settings.edit'),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
@@ -395,6 +407,7 @@ router.put('/word-filters/:id',
|
|||||||
|
|
||||||
router.delete('/word-filters/:id',
|
router.delete('/word-filters/:id',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
|
requirePermission('settings.edit'),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
@@ -9,7 +10,7 @@ const router = express.Router();
|
|||||||
/**
|
/**
|
||||||
* Get image security settings
|
* Get image security settings
|
||||||
*/
|
*/
|
||||||
router.get('/settings', adminAuth, async (req, res) => {
|
router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await db('app_settings')
|
const settings = await db('app_settings')
|
||||||
.whereIn('setting_key', [
|
.whereIn('setting_key', [
|
||||||
@@ -46,7 +47,7 @@ router.get('/settings', adminAuth, async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Update image security settings
|
* Update image security settings
|
||||||
*/
|
*/
|
||||||
router.put('/settings', adminAuth, async (req, res) => {
|
router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const updates = req.body;
|
const updates = req.body;
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ router.put('/settings', adminAuth, async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Get security monitoring dashboard data
|
* Get security monitoring dashboard data
|
||||||
*/
|
*/
|
||||||
router.get('/dashboard', adminAuth, async (req, res) => {
|
router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { timeframe = '24h' } = req.query;
|
const { timeframe = '24h' } = req.query;
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ router.get('/dashboard', adminAuth, async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Get detailed security logs
|
* Get detailed security logs
|
||||||
*/
|
*/
|
||||||
router.get('/logs', adminAuth, async (req, res) => {
|
router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -271,7 +272,7 @@ router.get('/logs', adminAuth, async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Get image access logs for a specific event
|
* Get image access logs for a specific event
|
||||||
*/
|
*/
|
||||||
router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
|
router.get('/events/:eventId/access-logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { page = 1, limit = 50 } = req.query;
|
const { page = 1, limit = 50 } = req.query;
|
||||||
@@ -321,7 +322,7 @@ router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Block/unblock suspicious IPs
|
* Block/unblock suspicious IPs
|
||||||
*/
|
*/
|
||||||
router.post('/block-ip', adminAuth, async (req, res) => {
|
router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { ip, action = 'block' } = req.body;
|
const { ip, action = 'block' } = req.body;
|
||||||
|
|
||||||
@@ -367,7 +368,7 @@ router.post('/block-ip', adminAuth, async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Clear security logs older than specified time
|
* Clear security logs older than specified time
|
||||||
*/
|
*/
|
||||||
router.delete('/logs/cleanup', adminAuth, async (req, res) => {
|
router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { olderThan = '30d' } = req.body;
|
const { olderThan = '30d' } = req.body;
|
||||||
|
|
||||||
@@ -424,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Export security data for analysis
|
* Export security data for analysis
|
||||||
*/
|
*/
|
||||||
router.get('/export', adminAuth, async (req, res) => {
|
router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { format = 'json', timeframe = '7d' } = req.query;
|
const { format = 'json', timeframe = '7d' } = req.query;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get notifications (unread activity logs)
|
// Get notifications (unread activity logs)
|
||||||
router.get('/', adminAuth, async (req, res) => {
|
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { limit = 20, includeRead = false } = req.query;
|
const { limit = 20, includeRead = false } = req.query;
|
||||||
|
|
||||||
@@ -64,7 +65,7 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark notification as read
|
// Mark notification as read
|
||||||
router.put('/:id/read', adminAuth, async (req, res) => {
|
router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
@@ -82,7 +83,7 @@ router.put('/:id/read', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark all notifications as read
|
// Mark all notifications as read
|
||||||
router.put('/read-all', adminAuth, async (req, res) => {
|
router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await db('activity_logs')
|
await db('activity_logs')
|
||||||
.whereNull('read_at')
|
.whereNull('read_at')
|
||||||
@@ -98,7 +99,7 @@ router.put('/read-all', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete old notifications (older than 30 days and read)
|
// Delete old notifications (older than 30 days and read)
|
||||||
router.delete('/clear-old', adminAuth, async (req, res) => {
|
router.delete('/clear-old', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Use database-agnostic date calculation
|
// Use database-agnostic date calculation
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const router = express.Router();
|
|||||||
const { body, query, validationResult } = require('express-validator');
|
const { body, query, validationResult } = require('express-validator');
|
||||||
const { db, withRetry } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
|
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
|
||||||
const { PhotoExportService } = require('../services/photoExportService');
|
const { PhotoExportService } = require('../services/photoExportService');
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ const exportService = new PhotoExportService();
|
|||||||
* GET /admin/photos/:eventId/filtered
|
* GET /admin/photos/:eventId/filtered
|
||||||
* Get filtered photos with pagination
|
* Get filtered photos with pagination
|
||||||
*/
|
*/
|
||||||
router.get('/:eventId/filtered', adminAuth, [
|
router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||||
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
|
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
|
||||||
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
|
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
|
||||||
query('has_likes').optional().isBoolean(),
|
query('has_likes').optional().isBoolean(),
|
||||||
@@ -131,7 +132,7 @@ router.get('/:eventId/filtered', adminAuth, [
|
|||||||
* GET /admin/photos/:eventId/filter-summary
|
* GET /admin/photos/:eventId/filter-summary
|
||||||
* Get just the summary counts for filter UI
|
* Get just the summary counts for filter UI
|
||||||
*/
|
*/
|
||||||
router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
|
router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const eventId = parseInt(req.params.eventId);
|
const eventId = parseInt(req.params.eventId);
|
||||||
|
|
||||||
@@ -153,7 +154,7 @@ router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
|
|||||||
* POST /admin/photos/:eventId/export
|
* POST /admin/photos/:eventId/export
|
||||||
* Export selected or filtered photos
|
* Export selected or filtered photos
|
||||||
*/
|
*/
|
||||||
router.post('/:eventId/export', adminAuth, [
|
router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), [
|
||||||
body('photo_ids').optional().isArray(),
|
body('photo_ids').optional().isArray(),
|
||||||
body('photo_ids.*').optional().isInt(),
|
body('photo_ids.*').optional().isInt(),
|
||||||
body('filter').optional().isObject(),
|
body('filter').optional().isObject(),
|
||||||
@@ -213,7 +214,7 @@ router.post('/:eventId/export', adminAuth, [
|
|||||||
* GET /admin/photos/export-formats
|
* GET /admin/photos/export-formats
|
||||||
* Get available export format options
|
* Get available export format options
|
||||||
*/
|
*/
|
||||||
router.get('/export-formats', adminAuth, (req, res) => {
|
router.get('/export-formats', adminAuth, requirePermission('photos.view'), (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: PhotoExportService.getFormatOptions()
|
data: PhotoExportService.getFormatOptions()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
@@ -109,7 +110,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
|||||||
|
|
||||||
// Upload photos for an event
|
// Upload photos for an event
|
||||||
// Max file count is configurable via general settings
|
// Max file count is configurable via general settings
|
||||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||||
let maxFilesPerUpload;
|
let maxFilesPerUpload;
|
||||||
try {
|
try {
|
||||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||||
@@ -420,7 +421,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete a photo
|
// Delete a photo
|
||||||
router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId, photoId } = req.params;
|
const { eventId, photoId } = req.params;
|
||||||
|
|
||||||
@@ -477,7 +478,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update a photo (e.g., change category)
|
// Update a photo (e.g., change category)
|
||||||
router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId, photoId } = req.params;
|
const { eventId, photoId } = req.params;
|
||||||
const { category_id } = req.body;
|
const { category_id } = req.body;
|
||||||
@@ -525,7 +526,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Bulk delete photos
|
// Bulk delete photos
|
||||||
router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
|
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { photoIds } = req.body;
|
const { photoIds } = req.body;
|
||||||
@@ -593,7 +594,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Bulk update photos
|
// Bulk update photos
|
||||||
router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { photoIds, updates } = req.body;
|
const { photoIds, updates } = req.body;
|
||||||
@@ -651,7 +652,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Download a photo
|
// Download a photo
|
||||||
router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => {
|
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId, photoId } = req.params;
|
const { eventId, photoId } = req.params;
|
||||||
|
|
||||||
@@ -683,7 +684,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get all photos for an event
|
// Get all photos for an event
|
||||||
router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||||
@@ -767,7 +768,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Serve photo with admin authentication
|
// Serve photo with admin authentication
|
||||||
router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId, photoId } = req.params;
|
const { eventId, photoId } = req.params;
|
||||||
|
|
||||||
@@ -804,7 +805,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Serve thumbnail with admin authentication
|
// Serve thumbnail with admin authentication
|
||||||
router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId, photoId } = req.params;
|
const { eventId, photoId } = req.params;
|
||||||
|
|
||||||
@@ -844,7 +845,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Debug endpoint to check photo existence
|
// Debug endpoint to check photo existence
|
||||||
router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
|
|
||||||
@@ -870,7 +871,7 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
// Initialize a chunked upload
|
// Initialize a chunked upload
|
||||||
router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
|
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
const { eventId } = req.params;
|
||||||
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
const { filename, fileSize, mimeType, totalChunks } = req.body;
|
||||||
@@ -908,7 +909,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upload a chunk
|
// Upload a chunk
|
||||||
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, async (req, res) => {
|
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { uploadId, chunkIndex } = req.params;
|
const { uploadId, chunkIndex } = req.params;
|
||||||
|
|
||||||
@@ -929,7 +930,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, a
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Complete chunked upload and process the file
|
// Complete chunked upload and process the file
|
||||||
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req, res) => {
|
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId, uploadId } = req.params;
|
const { eventId, uploadId } = req.params;
|
||||||
const { category_id } = req.body;
|
const { category_id } = req.body;
|
||||||
@@ -971,7 +972,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get upload status
|
// Get upload status
|
||||||
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, res) => {
|
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { uploadId } = req.params;
|
const { uploadId } = req.params;
|
||||||
|
|
||||||
@@ -989,7 +990,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, r
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Abort chunked upload
|
// Abort chunked upload
|
||||||
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, async (req, res) => {
|
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { uploadId } = req.params;
|
const { uploadId } = req.params;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { restoreService } = require('../services/restoreService');
|
const { restoreService } = require('../services/restoreService');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { body, query, validationResult } = require('express-validator');
|
const { body, query, validationResult } = require('express-validator');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
@@ -16,10 +17,36 @@ const fs = require('fs').promises;
|
|||||||
// Apply admin authentication to all routes
|
// Apply admin authentication to all routes
|
||||||
router.use(adminAuth);
|
router.use(adminAuth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform frontend S3 config to backend format
|
||||||
|
* Frontend sends: s3Endpoint, s3Bucket, s3AccessKey, s3SecretKey, s3Region
|
||||||
|
* Backend expects: endpoint, bucket, accessKeyId, secretAccessKey, region
|
||||||
|
*/
|
||||||
|
function transformS3Config(body) {
|
||||||
|
if (body.s3Config) {
|
||||||
|
// Already in correct format
|
||||||
|
return body.s3Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if frontend sent flat S3 config fields
|
||||||
|
if (body.s3Endpoint || body.s3Bucket || body.s3AccessKey || body.s3SecretKey) {
|
||||||
|
return {
|
||||||
|
endpoint: body.s3Endpoint,
|
||||||
|
bucket: body.s3Bucket,
|
||||||
|
accessKeyId: body.s3AccessKey,
|
||||||
|
secretAccessKey: body.s3SecretKey,
|
||||||
|
region: body.s3Region || 'us-east-1',
|
||||||
|
forcePathStyle: body.s3ForcePathStyle !== false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get restore service status and history
|
* Get restore service status and history
|
||||||
*/
|
*/
|
||||||
router.get('/status', async (req, res) => {
|
router.get('/status', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = parseInt(req.query.limit) || 10;
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
const history = await restoreService.getRestoreHistory(limit);
|
const history = await restoreService.getRestoreHistory(limit);
|
||||||
@@ -47,7 +74,7 @@ router.get('/status', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Validate restore request
|
* Validate restore request
|
||||||
*/
|
*/
|
||||||
router.post('/validate', [
|
router.post('/validate', requirePermission('backup.restore'), [
|
||||||
body('source').notEmpty().withMessage('Backup source is required'),
|
body('source').notEmpty().withMessage('Backup source is required'),
|
||||||
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
|
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
|
||||||
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
|
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
|
||||||
@@ -63,18 +90,38 @@ router.post('/validate', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Transform S3 config from frontend format
|
||||||
|
const s3Config = transformS3Config(req.body);
|
||||||
|
|
||||||
// Perform dry run validation
|
// Perform dry run validation
|
||||||
const result = await restoreService.restore({
|
const result = await restoreService.restore({
|
||||||
...req.body,
|
source: req.body.source,
|
||||||
|
manifestPath: req.body.manifestPath,
|
||||||
|
restoreType: req.body.restoreType,
|
||||||
|
selectedItems: req.body.selectedItems,
|
||||||
|
s3Config,
|
||||||
dryRun: true,
|
dryRun: true,
|
||||||
force: false
|
force: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Transform spaceCheck to match frontend expected format
|
||||||
|
const spaceCheck = result.spaceCheck ? {
|
||||||
|
sufficient: result.spaceCheck.hasEnoughSpace,
|
||||||
|
required: result.spaceCheck.requiredBytes,
|
||||||
|
available: result.spaceCheck.availableBytes,
|
||||||
|
requiredFormatted: result.spaceCheck.requiredFormatted,
|
||||||
|
availableFormatted: result.spaceCheck.availableFormatted,
|
||||||
|
// Keep original fields for backwards compatibility
|
||||||
|
hasEnoughSpace: result.spaceCheck.hasEnoughSpace,
|
||||||
|
requiredBytes: result.spaceCheck.requiredBytes,
|
||||||
|
availableBytes: result.spaceCheck.availableBytes
|
||||||
|
} : null;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
validation: result.validation,
|
validation: result.validation,
|
||||||
spaceCheck: result.spaceCheck,
|
spaceCheck,
|
||||||
logs: result.logs
|
logs: result.logs
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -82,7 +129,7 @@ router.post('/validate', [
|
|||||||
logger.error('Restore validation failed:', error);
|
logger.error('Restore validation failed:', error);
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Restore validation failed',
|
error: error.message || 'Restore validation failed',
|
||||||
logs: restoreService.restoreLog
|
logs: restoreService.restoreLog
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91,7 +138,7 @@ router.post('/validate', [
|
|||||||
/**
|
/**
|
||||||
* Start restore operation
|
* Start restore operation
|
||||||
*/
|
*/
|
||||||
router.post('/start', [
|
router.post('/start', requirePermission('backup.restore'), [
|
||||||
body('source').notEmpty().withMessage('Backup source is required'),
|
body('source').notEmpty().withMessage('Backup source is required'),
|
||||||
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
|
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
|
||||||
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
|
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
|
||||||
@@ -135,19 +182,28 @@ router.post('/start', [
|
|||||||
|
|
||||||
// Log restore attempt
|
// Log restore attempt
|
||||||
logger.warn('Restore operation started', {
|
logger.warn('Restore operation started', {
|
||||||
user: req.user.email,
|
user: req.admin.email,
|
||||||
ip: req.ip,
|
ip: req.ip,
|
||||||
restoreType: req.body.restoreType,
|
restoreType: req.body.restoreType,
|
||||||
source: req.body.source
|
source: req.body.source
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Transform S3 config from frontend format
|
||||||
|
const s3Config = transformS3Config(req.body);
|
||||||
|
|
||||||
// Start restore in background
|
// Start restore in background
|
||||||
restoreService.restore({
|
restoreService.restore({
|
||||||
...req.body,
|
source: req.body.source,
|
||||||
|
manifestPath: req.body.manifestPath,
|
||||||
|
restoreType: req.body.restoreType,
|
||||||
|
selectedItems: req.body.selectedItems,
|
||||||
|
skipPreBackup: req.body.skipPreBackup,
|
||||||
|
force: req.body.force,
|
||||||
|
s3Config,
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
operator: {
|
operator: {
|
||||||
type: 'manual',
|
type: 'manual',
|
||||||
userId: req.user.id,
|
userId: req.admin.id,
|
||||||
ip: req.ip
|
ip: req.ip
|
||||||
}
|
}
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -160,9 +216,10 @@ router.post('/start', [
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to start restore:', error);
|
logger.error('Failed to start restore:', error);
|
||||||
|
logger.error('Error stack:', error.stack);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Failed to start restore operation'
|
error: error.message || 'Failed to start restore operation'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -170,7 +227,7 @@ router.post('/start', [
|
|||||||
/**
|
/**
|
||||||
* Get current restore progress
|
* Get current restore progress
|
||||||
*/
|
*/
|
||||||
router.get('/progress', async (req, res) => {
|
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const progress = restoreService.getProgress();
|
const progress = restoreService.getProgress();
|
||||||
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
|
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
|
||||||
@@ -195,7 +252,7 @@ router.get('/progress', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Get restore run details
|
* Get restore run details
|
||||||
*/
|
*/
|
||||||
router.get('/run/:id', async (req, res) => {
|
router.get('/run/:id', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const run = await db('restore_runs')
|
const run = await db('restore_runs')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
@@ -250,7 +307,7 @@ router.get('/run/:id', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Get restore run report
|
* Get restore run report
|
||||||
*/
|
*/
|
||||||
router.get('/run/:id/report', async (req, res) => {
|
router.get('/run/:id/report', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const run = await db('restore_runs')
|
const run = await db('restore_runs')
|
||||||
.where('id', req.params.id)
|
.where('id', req.params.id)
|
||||||
@@ -289,7 +346,7 @@ router.get('/run/:id/report', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* List available backups for restore
|
* List available backups for restore
|
||||||
*/
|
*/
|
||||||
router.get('/available-backups', async (req, res) => {
|
router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const backups = [];
|
const backups = [];
|
||||||
|
|
||||||
@@ -349,10 +406,85 @@ router.get('/available-backups', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List backups for restore (POST version for frontend compatibility)
|
||||||
|
* Accepts source type in request body
|
||||||
|
*/
|
||||||
|
router.post('/list-backups', requirePermission('backup.view'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { source } = req.body; // 'local', 's3', or undefined for all
|
||||||
|
const backups = [];
|
||||||
|
|
||||||
|
// Get backup configuration
|
||||||
|
const backupConfig = await getBackupConfig();
|
||||||
|
|
||||||
|
// Get database backups from backup_runs table
|
||||||
|
const backupRuns = await db('backup_runs')
|
||||||
|
.where('status', 'completed')
|
||||||
|
.whereNotNull('manifest_path')
|
||||||
|
.orderBy('completed_at', 'desc')
|
||||||
|
.limit(20);
|
||||||
|
|
||||||
|
for (const run of backupRuns) {
|
||||||
|
const isS3 = run.manifest_path.startsWith('s3://');
|
||||||
|
const backupType = isS3 ? 's3' : 'local';
|
||||||
|
|
||||||
|
// Filter by source if specified
|
||||||
|
if (source && source !== backupType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
backups.push({
|
||||||
|
id: run.id,
|
||||||
|
type: backupType,
|
||||||
|
name: `Backup from ${new Date(run.completed_at).toLocaleString()}`,
|
||||||
|
path: run.manifest_path,
|
||||||
|
manifest_path: run.manifest_path,
|
||||||
|
manifestId: run.manifest_id,
|
||||||
|
manifestPath: run.manifest_path,
|
||||||
|
size: parseInt(run.total_size_bytes) || 0,
|
||||||
|
total_size: parseInt(run.total_size_bytes) || 0,
|
||||||
|
total_size_bytes: parseInt(run.total_size_bytes) || 0,
|
||||||
|
filesCount: run.files_backed_up || 0,
|
||||||
|
files_backed_up: run.files_backed_up || 0,
|
||||||
|
duration: run.duration_seconds,
|
||||||
|
duration_seconds: run.duration_seconds,
|
||||||
|
// Frontend expects snake_case date fields
|
||||||
|
created_at: run.completed_at,
|
||||||
|
completed_at: run.completed_at,
|
||||||
|
started_at: run.started_at,
|
||||||
|
// camelCase aliases
|
||||||
|
completedAt: run.completed_at,
|
||||||
|
startedAt: run.started_at,
|
||||||
|
// Backup metadata
|
||||||
|
status: run.status,
|
||||||
|
backup_type: run.backup_type,
|
||||||
|
backupType: run.backup_type,
|
||||||
|
backup_mode: run.backup_mode,
|
||||||
|
backupMode: run.backup_mode,
|
||||||
|
app_version: run.app_version,
|
||||||
|
appVersion: run.app_version
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: backups,
|
||||||
|
source: source || 'all'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to list backups for restore:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to list backups for restore'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get restore settings
|
* Get restore settings
|
||||||
*/
|
*/
|
||||||
router.get('/settings', async (req, res) => {
|
router.get('/settings', requirePermission('backup.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await getRestoreSettings();
|
const settings = await getRestoreSettings();
|
||||||
res.json({
|
res.json({
|
||||||
@@ -371,7 +503,7 @@ router.get('/settings', async (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* Update restore settings
|
* Update restore settings
|
||||||
*/
|
*/
|
||||||
router.put('/settings', [
|
router.put('/settings', requirePermission('backup.restore'), [
|
||||||
body('restore_allow_force').optional().isBoolean(),
|
body('restore_allow_force').optional().isBoolean(),
|
||||||
body('restore_require_pre_backup').optional().isBoolean(),
|
body('restore_require_pre_backup').optional().isBoolean(),
|
||||||
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
|
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { body, validationResult } = require('express-validator');
|
|||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||||
const {
|
const {
|
||||||
@@ -92,7 +93,7 @@ const faviconUpload = multer({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get all settings
|
// Get all settings
|
||||||
router.get('/', adminAuth, async (req, res) => {
|
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await db('app_settings').select('*');
|
const settings = await db('app_settings').select('*');
|
||||||
|
|
||||||
@@ -120,7 +121,7 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get settings by type
|
// Get settings by type
|
||||||
router.get('/:type', adminAuth, async (req, res) => {
|
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { type } = req.params;
|
const { type } = req.params;
|
||||||
const settings = await db('app_settings')
|
const settings = await db('app_settings')
|
||||||
@@ -151,7 +152,7 @@ router.get('/:type', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get password complexity settings for frontend
|
// Get password complexity settings for frontend
|
||||||
router.get('/password/complexity', adminAuth, async (req, res) => {
|
router.get('/password/complexity', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
|
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
|
||||||
|
|
||||||
@@ -172,7 +173,7 @@ router.get('/password/complexity', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update branding settings
|
// Update branding settings
|
||||||
router.put('/branding', adminAuth, async (req, res) => {
|
router.put('/branding', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
company_name,
|
company_name,
|
||||||
@@ -313,7 +314,7 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upload logo
|
// Upload logo
|
||||||
router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
|
router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.single('logo'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No logo file uploaded' });
|
return res.status(400).json({ error: 'No logo file uploaded' });
|
||||||
@@ -375,7 +376,7 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upload watermark logo
|
// Upload watermark logo
|
||||||
router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => {
|
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
@@ -437,7 +438,7 @@ router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update theme settings
|
// Update theme settings
|
||||||
router.put('/theme', adminAuth, async (req, res) => {
|
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const themeSettings = req.body;
|
const themeSettings = req.body;
|
||||||
|
|
||||||
@@ -474,7 +475,7 @@ router.put('/theme', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update general settings
|
// Update general settings
|
||||||
router.put('/general', adminAuth, async (req, res) => {
|
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = { ...req.body };
|
const settings = { ...req.body };
|
||||||
let uploadLimitTouched = false;
|
let uploadLimitTouched = false;
|
||||||
@@ -573,7 +574,7 @@ router.put('/general', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update security settings
|
// Update security settings
|
||||||
router.put('/security', adminAuth, async (req, res) => {
|
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = req.body;
|
const settings = req.body;
|
||||||
|
|
||||||
@@ -612,7 +613,7 @@ router.put('/security', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update analytics settings
|
// Update analytics settings
|
||||||
router.put('/analytics', adminAuth, async (req, res) => {
|
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = req.body;
|
const settings = req.body;
|
||||||
|
|
||||||
@@ -649,7 +650,7 @@ router.put('/analytics', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get storage info
|
// Get storage info
|
||||||
router.get('/storage/info', adminAuth, async (req, res) => {
|
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get total storage used
|
// Get total storage used
|
||||||
const totalStorage = await db('photos')
|
const totalStorage = await db('photos')
|
||||||
@@ -877,7 +878,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Upload favicon endpoint
|
// Upload favicon endpoint
|
||||||
router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => {
|
router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUpload.single('favicon'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: 'No favicon file provided' });
|
return res.status(400).json({ error: 'No favicon file provided' });
|
||||||
@@ -915,7 +916,7 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update rate limit settings
|
// Update rate limit settings
|
||||||
router.put('/security/rate-limit', adminAuth, [
|
router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit'), [
|
||||||
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
|
||||||
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
|
||||||
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
|
||||||
@@ -979,7 +980,7 @@ router.put('/security/rate-limit', adminAuth, [
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get default public site template
|
// Get default public site template
|
||||||
router.get('/public-site/default', adminAuth, async (req, res) => {
|
router.get('/public-site/default', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const defaults = await getDefaultPublicSitePayload();
|
const defaults = await getDefaultPublicSitePayload();
|
||||||
|
|
||||||
@@ -1000,7 +1001,7 @@ router.get('/public-site/default', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reset public site template to defaults
|
// Reset public site template to defaults
|
||||||
router.post('/public-site/reset', adminAuth, async (req, res) => {
|
router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const entries = [
|
const entries = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db, withRetry } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get system version
|
// Get system version
|
||||||
router.get('/version', adminAuth, async (req, res) => {
|
router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Read backend version from package.json
|
// Read backend version from package.json
|
||||||
let backendVersion = '1.0.0';
|
let backendVersion = '1.0.0';
|
||||||
@@ -35,7 +36,7 @@ router.get('/version', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get comprehensive system status
|
// Get comprehensive system status
|
||||||
router.get('/status', adminAuth, async (req, res) => {
|
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Database size - check if PostgreSQL or SQLite
|
// Database size - check if PostgreSQL or SQLite
|
||||||
let dbSize = 0;
|
let dbSize = 0;
|
||||||
@@ -170,7 +171,7 @@ router.get('/status', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get database statistics
|
// Get database statistics
|
||||||
router.get('/database', adminAuth, async (req, res) => {
|
router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get table info
|
// Get table info
|
||||||
const tables = [
|
const tables = [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { generateThumbnail } = require('../services/imageProcessor');
|
const { generateThumbnail } = require('../services/imageProcessor');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
@@ -10,7 +11,7 @@ const logger = require('../utils/logger');
|
|||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
// Get thumbnail settings
|
// Get thumbnail settings
|
||||||
router.get('/settings', adminAuth, async (req, res) => {
|
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = await db('app_settings')
|
const settings = await db('app_settings')
|
||||||
.whereIn('key', [
|
.whereIn('key', [
|
||||||
@@ -42,7 +43,7 @@ router.get('/settings', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update thumbnail settings
|
// Update thumbnail settings
|
||||||
router.put('/settings', adminAuth, async (req, res) => {
|
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { width, height, fit, quality, format } = req.body;
|
const { width, height, fit, quality, format } = req.body;
|
||||||
|
|
||||||
@@ -91,7 +92,7 @@ router.put('/settings', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Regenerate all thumbnails with new settings
|
// Regenerate all thumbnails with new settings
|
||||||
router.post('/regenerate', adminAuth, async (req, res) => {
|
router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.body; // Optional: regenerate for specific event only
|
const { eventId } = req.body; // Optional: regenerate for specific event only
|
||||||
|
|
||||||
@@ -164,7 +165,7 @@ router.post('/regenerate', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get regeneration status
|
// Get regeneration status
|
||||||
router.get('/regenerate/status', adminAuth, async (req, res) => {
|
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Count photos with and without thumbnails
|
// Count photos with and without thumbnails
|
||||||
const totalPhotos = await db('photos').count('id as count').first();
|
const totalPhotos = await db('photos').count('id as count').first();
|
||||||
|
|||||||
@@ -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
@@ -70,52 +70,65 @@ router.post('/admin/login', [
|
|||||||
logger.warn('Suspicious login pattern detected', { username, ipAddress });
|
logger.warn('Suspicious login pattern detected', { username, ipAddress });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch admin with role information
|
||||||
const admin = await db('admin_users')
|
const admin = await db('admin_users')
|
||||||
.where({ username })
|
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||||
.orWhere({ email: username })
|
.where('admin_users.username', username)
|
||||||
|
.orWhere('admin_users.email', username)
|
||||||
|
.select(
|
||||||
|
'admin_users.*',
|
||||||
|
'roles.name as role_name',
|
||||||
|
'roles.display_name as role_display_name'
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
// Use generic error to prevent user enumeration
|
// Use generic error to prevent user enumeration
|
||||||
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
||||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||||
return res.status(401).json({ error: getGenericAuthError() });
|
return res.status(401).json({ error: getGenericAuthError() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!admin.is_active) {
|
if (!admin.is_active) {
|
||||||
await trackFailedAttempt(username, ipAddress, userAgent);
|
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||||
return res.status(401).json({ error: getGenericAuthError() });
|
return res.status(401).json({ error: getGenericAuthError() });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful login
|
// Successful login
|
||||||
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
await trackSuccessfulLogin(username, ipAddress, userAgent);
|
||||||
|
|
||||||
// Update last login and login metadata
|
// 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: new Date(),
|
||||||
last_login_ip: ipAddress
|
last_login_ip: ipAddress
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate token with additional claims
|
// Generate token with additional claims including role
|
||||||
const token = jwt.sign({
|
const token = jwt.sign({
|
||||||
id: admin.id,
|
id: admin.id,
|
||||||
username: admin.username,
|
username: admin.username,
|
||||||
type: 'admin',
|
type: 'admin',
|
||||||
|
role: admin.role_name, // Add role to JWT
|
||||||
ip: ipAddress,
|
ip: ipAddress,
|
||||||
loginTime: Date.now()
|
loginTime: Date.now()
|
||||||
}, process.env.JWT_SECRET, {
|
}, process.env.JWT_SECRET, {
|
||||||
expiresIn: '24h',
|
expiresIn: '24h',
|
||||||
issuer: 'picpeak-auth'
|
issuer: 'picpeak-auth'
|
||||||
});
|
});
|
||||||
|
|
||||||
setAdminAuthCookie(res, token);
|
setAdminAuthCookie(res, token);
|
||||||
|
|
||||||
|
// Include role in response
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
user: {
|
user: {
|
||||||
id: admin.id,
|
id: admin.id,
|
||||||
username: admin.username,
|
username: admin.username,
|
||||||
email: admin.email,
|
email: admin.email,
|
||||||
mustChangePassword: admin.must_change_password || false
|
mustChangePassword: admin.must_change_password || false,
|
||||||
|
role: admin.role_name ? {
|
||||||
|
name: admin.role_name,
|
||||||
|
displayName: admin.role_display_name
|
||||||
|
} : null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -728,15 +728,15 @@ async function runBackupInternal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const schemaVersion = await getCurrentSchemaVersion();
|
const schemaVersion = await getCurrentSchemaVersion();
|
||||||
const [insertedId] = await db('backup_runs').insert({
|
const insertResult = await db('backup_runs').insert({
|
||||||
started_at: startTime,
|
started_at: startTime,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
backup_type: 'scheduled',
|
backup_type: 'scheduled',
|
||||||
app_version: packageJson.version,
|
app_version: packageJson.version,
|
||||||
node_version: process.version,
|
node_version: process.version,
|
||||||
db_schema_version: schemaVersion
|
db_schema_version: schemaVersion
|
||||||
});
|
}).returning('id');
|
||||||
runId = insertedId;
|
runId = insertResult[0]?.id || insertResult[0];
|
||||||
|
|
||||||
const files = await service.getFilesToBackup(config.backup_include_archived);
|
const files = await service.getFilesToBackup(config.backup_include_archived);
|
||||||
logger.info(`Found ${files.length} files to check for backup`);
|
logger.info(`Found ${files.length} files to check for backup`);
|
||||||
@@ -820,11 +820,17 @@ async function runBackupInternal() {
|
|||||||
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
|
||||||
manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null,
|
manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null,
|
||||||
statistics: JSON.stringify({
|
statistics: JSON.stringify({
|
||||||
|
// Use snake_case for frontend compatibility
|
||||||
|
files_processed: result.backedUpCount,
|
||||||
|
total_size: result.backedUpSize,
|
||||||
|
total_files_checked: files.length,
|
||||||
|
average_file_size: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
||||||
|
destination: destinationType,
|
||||||
|
// Keep camelCase for backward compatibility
|
||||||
totalFilesChecked: files.length,
|
totalFilesChecked: files.length,
|
||||||
filesBackedUp: result.backedUpCount,
|
filesBackedUp: result.backedUpCount,
|
||||||
totalSize: result.backedUpSize,
|
totalSize: result.backedUpSize,
|
||||||
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
|
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0
|
||||||
destination: destinationType
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -927,22 +933,54 @@ async function triggerManualBackup() {
|
|||||||
|
|
||||||
async function getBackupStatus(limit = 10) {
|
async function getBackupStatus(limit = 10) {
|
||||||
try {
|
try {
|
||||||
const runs = await db('backup_runs')
|
const rawRuns = await db('backup_runs')
|
||||||
.orderBy('started_at', 'desc')
|
.orderBy('started_at', 'desc')
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
|
// Transform runs to add frontend-compatible field aliases
|
||||||
|
const runs = rawRuns.map(run => {
|
||||||
|
// Parse and transform statistics to snake_case for frontend compatibility
|
||||||
|
let statistics = run.statistics;
|
||||||
|
if (statistics) {
|
||||||
|
// Handle both string (SQLite) and object (PostgreSQL JSONB) types
|
||||||
|
let stats = statistics;
|
||||||
|
if (typeof statistics === 'string') {
|
||||||
|
try {
|
||||||
|
stats = JSON.parse(statistics);
|
||||||
|
} catch (e) {
|
||||||
|
stats = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add snake_case aliases for frontend
|
||||||
|
statistics = {
|
||||||
|
...stats,
|
||||||
|
files_processed: stats.filesBackedUp || stats.files_processed || 0,
|
||||||
|
total_size: stats.totalSize || stats.total_size || 0,
|
||||||
|
total_files_checked: stats.totalFilesChecked || stats.total_files_checked || 0,
|
||||||
|
average_file_size: stats.averageFileSize || stats.average_file_size || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...run,
|
||||||
|
created_at: run.started_at, // Alias for frontend compatibility
|
||||||
|
statistics
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const lastRun = runs[0];
|
const lastRun = runs[0];
|
||||||
let manifestValid = false;
|
let manifestValid = false;
|
||||||
|
|
||||||
if (lastRun && lastRun.manifest_path) {
|
if (lastRun && lastRun.manifest_path) {
|
||||||
try {
|
try {
|
||||||
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
|
// Use validateBackupManifest which handles both local and S3 paths
|
||||||
if (backupManifest.validateManifest) {
|
const result = await validateBackupManifest(lastRun.manifest_path);
|
||||||
backupManifest.validateManifest(manifest);
|
manifestValid = result.valid;
|
||||||
|
if (!result.valid) {
|
||||||
|
logger.warn('Manifest validation failed:', result.error);
|
||||||
}
|
}
|
||||||
manifestValid = true;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Manifest validation failed:', error);
|
logger.warn('Manifest validation failed:', error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +989,7 @@ async function getBackupStatus(limit = 10) {
|
|||||||
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
|
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
|
||||||
lastRun: lastRun ? { ...lastRun, manifestValid } : null,
|
lastRun: lastRun ? { ...lastRun, manifestValid } : null,
|
||||||
recentRuns: runs,
|
recentRuns: runs,
|
||||||
|
recentBackups: runs, // Alias for frontend compatibility
|
||||||
nextScheduledRun: getNextScheduledRun()
|
nextScheduledRun: getNextScheduledRun()
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -153,6 +153,9 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
if (processedVariables.archive_date) {
|
if (processedVariables.archive_date) {
|
||||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||||
}
|
}
|
||||||
|
if (processedVariables.expires_at) {
|
||||||
|
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
|
||||||
|
}
|
||||||
|
|
||||||
// Format welcome message for HTML display (preserve line breaks)
|
// Format welcome message for HTML display (preserve line breaks)
|
||||||
if (processedVariables.welcome_message) {
|
if (processedVariables.welcome_message) {
|
||||||
|
|||||||
@@ -75,14 +75,15 @@ class RestoreService {
|
|||||||
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
|
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
|
||||||
|
|
||||||
// Create restore run record
|
// Create restore run record
|
||||||
const [runId] = await db('restore_runs').insert({
|
const result = await db('restore_runs').insert({
|
||||||
started_at: startTime,
|
started_at: startTime,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
restore_type: options.restoreType,
|
restore_type: options.restoreType,
|
||||||
source: options.source,
|
source: options.source,
|
||||||
manifest_path: options.manifestPath,
|
manifest_path: options.manifestPath,
|
||||||
is_dry_run: options.dryRun || false
|
is_dry_run: options.dryRun || false
|
||||||
});
|
}).returning('id');
|
||||||
|
const runId = Array.isArray(result) ? (result[0]?.id || result[0]) : result;
|
||||||
|
|
||||||
restoreRun = { id: runId };
|
restoreRun = { id: runId };
|
||||||
|
|
||||||
@@ -105,7 +106,8 @@ class RestoreService {
|
|||||||
|
|
||||||
if (validation.warnings.length > 0) {
|
if (validation.warnings.length > 0) {
|
||||||
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings });
|
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings });
|
||||||
if (!options.force) {
|
// Only block actual restores (not dry runs/validations) on warnings
|
||||||
|
if (!options.force && !options.dryRun) {
|
||||||
throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`);
|
throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -400,29 +402,55 @@ class RestoreService {
|
|||||||
* Check available disk space
|
* Check available disk space
|
||||||
*/
|
*/
|
||||||
async checkDiskSpace(manifest, options) {
|
async checkDiskSpace(manifest, options) {
|
||||||
const { statvfs } = require('fs');
|
|
||||||
const statvfsAsync = promisify(statvfs);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const stats = await statvfsAsync(storagePath);
|
|
||||||
|
|
||||||
const blockSize = stats.bsize || stats.f_bsize || 4096;
|
|
||||||
const availableBytes = stats.bavail * blockSize;
|
|
||||||
|
|
||||||
// Calculate required space (with 20% buffer)
|
// Calculate required space (with 20% buffer)
|
||||||
let requiredBytes = 0;
|
let requiredBytes = 0;
|
||||||
if (options.restoreType === 'full' || options.restoreType === 'files') {
|
if (options.restoreType === 'full' || options.restoreType === 'files') {
|
||||||
requiredBytes = manifest.files.total_size * 1.2;
|
requiredBytes = (manifest.files?.total_size || 0) * 1.2;
|
||||||
}
|
}
|
||||||
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
if (options.restoreType === 'full' || options.restoreType === 'database') {
|
||||||
requiredBytes += (manifest.database.size || 0) * 1.2;
|
requiredBytes += (manifest.database?.size || 0) * 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get disk space using df command (works on Linux and macOS)
|
||||||
|
let availableBytes = 0;
|
||||||
|
let diskCheckSucceeded = false;
|
||||||
|
try {
|
||||||
|
const { exec } = require('child_process');
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
// Use root path as fallback if storage path doesn't exist yet
|
||||||
|
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
||||||
|
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
|
||||||
|
const parsed = parseInt(stdout.trim());
|
||||||
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
|
availableBytes = parsed * 1024; // Convert from KB to bytes
|
||||||
|
diskCheckSucceeded = true;
|
||||||
|
}
|
||||||
|
} catch (dfError) {
|
||||||
|
this.log('warn', 'Could not determine available disk space', { error: dfError.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If disk check failed, return optimistic result
|
||||||
|
if (!diskCheckSucceeded) {
|
||||||
|
return {
|
||||||
|
hasEnoughSpace: true,
|
||||||
|
availableBytes: null, // null indicates unknown
|
||||||
|
requiredBytes,
|
||||||
|
availableFormatted: 'Unknown',
|
||||||
|
requiredFormatted: this.formatBytes(requiredBytes)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add space for pre-restore backup
|
// Add space for pre-restore backup
|
||||||
if (!options.skipPreBackup) {
|
if (!options.skipPreBackup) {
|
||||||
const currentUsage = await this.calculateCurrentStorageUsage();
|
try {
|
||||||
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
|
const currentUsage = await this.calculateCurrentStorageUsage();
|
||||||
|
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors calculating current usage
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -434,11 +462,11 @@ class RestoreService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Fallback for systems without statvfs
|
// Fallback for any errors
|
||||||
this.log('warn', 'Could not check disk space', { error: error.message });
|
this.log('warn', 'Could not check disk space', { error: error.message });
|
||||||
return {
|
return {
|
||||||
hasEnoughSpace: true, // Assume we have space if we can't check
|
hasEnoughSpace: true, // Assume we have space if we can't check
|
||||||
availableBytes: 0,
|
availableBytes: null,
|
||||||
requiredBytes: 0,
|
requiredBytes: 0,
|
||||||
availableFormatted: 'Unknown',
|
availableFormatted: 'Unknown',
|
||||||
requiredFormatted: 'Unknown'
|
requiredFormatted: 'Unknown'
|
||||||
|
|||||||
@@ -76,12 +76,22 @@ class S3StorageAdapter extends stream.EventEmitter {
|
|||||||
|
|
||||||
// Add custom endpoint if provided (for S3-compatible services)
|
// Add custom endpoint if provided (for S3-compatible services)
|
||||||
if (this.config.endpoint) {
|
if (this.config.endpoint) {
|
||||||
s3Config.endpoint = this.config.endpoint;
|
let endpoint = this.config.endpoint;
|
||||||
// For MinIO and other S3-compatible services
|
|
||||||
if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) {
|
// Only add protocol if endpoint doesn't already have one
|
||||||
s3Config.endpoint = `https://${this.config.endpoint}`;
|
const hasProtocol = endpoint.startsWith('http://') || endpoint.startsWith('https://');
|
||||||
} else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) {
|
if (!hasProtocol) {
|
||||||
s3Config.endpoint = `http://${this.config.endpoint}`;
|
// Add protocol based on sslEnabled setting
|
||||||
|
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Config.endpoint = endpoint;
|
||||||
|
|
||||||
|
// For S3-compatible services with custom endpoints, force path style
|
||||||
|
// This is required for MinIO and when using IP addresses
|
||||||
|
if (!s3Config.forcePathStyle) {
|
||||||
|
s3Config.forcePathStyle = true;
|
||||||
|
logger.info('Automatically enabling forcePathStyle for custom S3 endpoint');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
/**
|
||||||
|
* User Management Service for Admin Users
|
||||||
|
* Handles invitations, user CRUD, and role management
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||||
|
const { getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
|
const { queueEmail } = require('./emailProcessor');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new admin user invitation
|
||||||
|
* @param {object} params - { email, roleId, invitedById }
|
||||||
|
* @returns {Promise<object>} Created invitation details
|
||||||
|
*/
|
||||||
|
async function createInvitation({ email, roleId, invitedById }) {
|
||||||
|
// Check if email already exists
|
||||||
|
const existingUser = await db('admin_users').where('email', email).first();
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ConflictError('User with this email already exists', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pending invitation
|
||||||
|
const pendingInvite = await db('admin_invitations')
|
||||||
|
.where('email', email)
|
||||||
|
.whereNull('accepted_at')
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (pendingInvite) {
|
||||||
|
throw new ConflictError('Pending invitation already exists for this email', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role exists
|
||||||
|
const role = await db('roles').where('id', roleId).first();
|
||||||
|
if (!role) {
|
||||||
|
throw new NotFoundError('Role', roleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate secure invitation token (64 characters hex = 32 bytes)
|
||||||
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||||
|
|
||||||
|
const [invitationId] = await db('admin_invitations').insert({
|
||||||
|
email,
|
||||||
|
token,
|
||||||
|
role_id: roleId,
|
||||||
|
invited_by: invitedById,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
created_at: new Date()
|
||||||
|
}).returning('id');
|
||||||
|
|
||||||
|
const id = invitationId?.id || invitationId;
|
||||||
|
|
||||||
|
// Queue invitation email
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
|
||||||
|
await queueEmail(null, email, 'admin_invitation', {
|
||||||
|
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
|
||||||
|
role_name: role.display_name,
|
||||||
|
expires_at: expiresAt.toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity('admin_invitation_created',
|
||||||
|
{ email, roleId, roleName: role.display_name },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: invitedById, name: 'system' }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Admin invitation created', { email, roleId, invitedById });
|
||||||
|
|
||||||
|
return { id, email, token, role: role.display_name, expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an invitation and create the admin user
|
||||||
|
* @param {object} params - { token, username, password }
|
||||||
|
* @returns {Promise<object>} Created user details
|
||||||
|
*/
|
||||||
|
async function acceptInvitation({ token, username, password }) {
|
||||||
|
const invitation = await db('admin_invitations')
|
||||||
|
.where('token', token)
|
||||||
|
.whereNull('accepted_at')
|
||||||
|
.where('expires_at', '>', new Date())
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!invitation) {
|
||||||
|
throw new ValidationError('Invalid or expired invitation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check username availability
|
||||||
|
const existingUsername = await db('admin_users').where('username', username).first();
|
||||||
|
if (existingUsername) {
|
||||||
|
throw new ConflictError('Username already taken', 'username');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email not taken (race condition protection)
|
||||||
|
const existingEmail = await db('admin_users').where('email', invitation.email).first();
|
||||||
|
if (existingEmail) {
|
||||||
|
throw new ConflictError('Email already registered', 'email');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
|
||||||
|
|
||||||
|
// Create user in transaction
|
||||||
|
const result = await db.transaction(async (trx) => {
|
||||||
|
const [userId] = await trx('admin_users').insert({
|
||||||
|
username,
|
||||||
|
email: invitation.email,
|
||||||
|
password_hash: passwordHash,
|
||||||
|
role_id: invitation.role_id,
|
||||||
|
created_by: invitation.invited_by,
|
||||||
|
is_active: formatBoolean(true),
|
||||||
|
must_change_password: formatBoolean(false),
|
||||||
|
invite_accepted_at: new Date(),
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
}).returning('id');
|
||||||
|
|
||||||
|
const id = userId?.id || userId;
|
||||||
|
|
||||||
|
// Mark invitation as accepted
|
||||||
|
await trx('admin_invitations')
|
||||||
|
.where('id', invitation.id)
|
||||||
|
.update({
|
||||||
|
accepted_at: new Date(),
|
||||||
|
accepted_user_id: id
|
||||||
|
});
|
||||||
|
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity('admin_invitation_accepted',
|
||||||
|
{ userId: result, email: invitation.email },
|
||||||
|
null,
|
||||||
|
{ type: 'system', id: null, name: 'system' }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Admin invitation accepted', {
|
||||||
|
userId: result,
|
||||||
|
email: invitation.email,
|
||||||
|
invitationId: invitation.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return { userId: result, email: invitation.email };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all admin users with their roles
|
||||||
|
* @returns {Promise<object[]>}
|
||||||
|
*/
|
||||||
|
async function getAllAdminUsers() {
|
||||||
|
return db('admin_users')
|
||||||
|
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||||
|
.leftJoin('admin_users as creator', 'creator.id', 'admin_users.created_by')
|
||||||
|
.select(
|
||||||
|
'admin_users.id',
|
||||||
|
'admin_users.username',
|
||||||
|
'admin_users.email',
|
||||||
|
'admin_users.is_active',
|
||||||
|
'admin_users.last_login',
|
||||||
|
'admin_users.last_login_ip',
|
||||||
|
'admin_users.created_at',
|
||||||
|
'admin_users.updated_at',
|
||||||
|
'roles.id as role_id',
|
||||||
|
'roles.name as role_name',
|
||||||
|
'roles.display_name as role_display_name',
|
||||||
|
'creator.username as created_by_username'
|
||||||
|
)
|
||||||
|
.orderBy('admin_users.created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get single admin user by ID
|
||||||
|
* @param {number} id - User ID
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
async function getAdminUserById(id) {
|
||||||
|
const user = await db('admin_users')
|
||||||
|
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
|
||||||
|
.where('admin_users.id', id)
|
||||||
|
.select(
|
||||||
|
'admin_users.id',
|
||||||
|
'admin_users.username',
|
||||||
|
'admin_users.email',
|
||||||
|
'admin_users.is_active',
|
||||||
|
'admin_users.last_login',
|
||||||
|
'admin_users.last_login_ip',
|
||||||
|
'admin_users.created_at',
|
||||||
|
'admin_users.updated_at',
|
||||||
|
'roles.id as role_id',
|
||||||
|
'roles.name as role_name',
|
||||||
|
'roles.display_name as role_display_name'
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Admin user', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update admin user
|
||||||
|
* @param {number} id - User ID to update
|
||||||
|
* @param {object} updates - Fields to update
|
||||||
|
* @param {number} updatedById - ID of user making the update
|
||||||
|
* @returns {Promise<object>} Updated user
|
||||||
|
*/
|
||||||
|
async function updateAdminUser(id, updates, updatedById) {
|
||||||
|
const user = await db('admin_users').where('id', id).first();
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Admin user', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedUpdates = {};
|
||||||
|
|
||||||
|
if (updates.username !== undefined) {
|
||||||
|
const existing = await db('admin_users')
|
||||||
|
.where('username', updates.username)
|
||||||
|
.whereNot('id', id)
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Username already taken', 'username');
|
||||||
|
}
|
||||||
|
allowedUpdates.username = updates.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.email !== undefined) {
|
||||||
|
const existing = await db('admin_users')
|
||||||
|
.where('email', updates.email)
|
||||||
|
.whereNot('id', id)
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictError('Email already in use', 'email');
|
||||||
|
}
|
||||||
|
allowedUpdates.email = updates.email;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.role_id !== undefined) {
|
||||||
|
const role = await db('roles').where('id', updates.role_id).first();
|
||||||
|
if (!role) {
|
||||||
|
throw new NotFoundError('Role', updates.role_id);
|
||||||
|
}
|
||||||
|
allowedUpdates.role_id = updates.role_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.is_active !== undefined) {
|
||||||
|
allowedUpdates.is_active = formatBoolean(updates.is_active);
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedUpdates.updated_at = new Date();
|
||||||
|
|
||||||
|
await db('admin_users').where('id', id).update(allowedUpdates);
|
||||||
|
|
||||||
|
await logActivity('admin_user_updated',
|
||||||
|
{ userId: id, changes: Object.keys(allowedUpdates) },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: updatedById, name: 'system' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return getAdminUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate admin user
|
||||||
|
* @param {number} id - User ID to deactivate
|
||||||
|
* @param {number} deactivatedById - ID of user performing deactivation
|
||||||
|
*/
|
||||||
|
async function deactivateAdminUser(id, deactivatedById) {
|
||||||
|
const user = await db('admin_users').where('id', id).first();
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Admin user', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent self-deactivation
|
||||||
|
if (id === deactivatedById) {
|
||||||
|
throw new ValidationError('Cannot deactivate your own account');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the last super_admin
|
||||||
|
const superAdminRole = await db('roles').where('name', 'super_admin').first();
|
||||||
|
if (user.role_id === superAdminRole?.id) {
|
||||||
|
const superAdminCount = await db('admin_users')
|
||||||
|
.where('role_id', superAdminRole.id)
|
||||||
|
.where('is_active', formatBoolean(true))
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (Number(superAdminCount?.count) <= 1) {
|
||||||
|
throw new ValidationError('Cannot deactivate the last Super Admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('admin_users').where('id', id).update({
|
||||||
|
is_active: formatBoolean(false),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity('admin_user_deactivated',
|
||||||
|
{ userId: id, username: user.username },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: deactivatedById, name: 'system' }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Admin user deactivated', { userId: id, deactivatedById });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset admin user password (generates new password)
|
||||||
|
* @param {number} id - User ID
|
||||||
|
* @param {number} resetById - ID of user performing reset
|
||||||
|
* @returns {Promise<object>} Result with email and status
|
||||||
|
*/
|
||||||
|
async function resetAdminPassword(id, resetById) {
|
||||||
|
const user = await db('admin_users').where('id', id).first();
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundError('Admin user', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPassword = generateReadablePassword();
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
|
||||||
|
|
||||||
|
await db('admin_users').where('id', id).update({
|
||||||
|
password_hash: passwordHash,
|
||||||
|
must_change_password: formatBoolean(true),
|
||||||
|
password_changed_at: new Date(),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue password reset email
|
||||||
|
await queueEmail(null, user.email, 'admin_password_reset', {
|
||||||
|
username: user.username,
|
||||||
|
new_password: newPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity('admin_password_reset',
|
||||||
|
{ userId: id, username: user.username },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: resetById, name: 'system' }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Admin password reset', { userId: id, resetById });
|
||||||
|
|
||||||
|
return { email: user.email, passwordSent: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all roles
|
||||||
|
* @returns {Promise<object[]>}
|
||||||
|
*/
|
||||||
|
async function getAllRoles() {
|
||||||
|
return db('roles')
|
||||||
|
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
|
||||||
|
.orderBy('priority', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending invitations
|
||||||
|
* @returns {Promise<object[]>}
|
||||||
|
*/
|
||||||
|
async function getPendingInvitations() {
|
||||||
|
return db('admin_invitations')
|
||||||
|
.join('roles', 'roles.id', 'admin_invitations.role_id')
|
||||||
|
.join('admin_users', 'admin_users.id', 'admin_invitations.invited_by')
|
||||||
|
.whereNull('admin_invitations.accepted_at')
|
||||||
|
.where('admin_invitations.expires_at', '>', new Date())
|
||||||
|
.select(
|
||||||
|
'admin_invitations.id',
|
||||||
|
'admin_invitations.email',
|
||||||
|
'admin_invitations.expires_at',
|
||||||
|
'admin_invitations.created_at',
|
||||||
|
'roles.display_name as role_name',
|
||||||
|
'admin_users.username as invited_by'
|
||||||
|
)
|
||||||
|
.orderBy('admin_invitations.created_at', 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel/delete an invitation
|
||||||
|
* @param {number} id - Invitation ID
|
||||||
|
* @param {number} cancelledById - ID of user cancelling
|
||||||
|
*/
|
||||||
|
async function cancelInvitation(id, cancelledById) {
|
||||||
|
const invitation = await db('admin_invitations').where('id', id).first();
|
||||||
|
if (!invitation) {
|
||||||
|
throw new NotFoundError('Invitation', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('admin_invitations').where('id', id).del();
|
||||||
|
|
||||||
|
await logActivity('admin_invitation_cancelled',
|
||||||
|
{ invitationId: id, email: invitation.email },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: cancelledById, name: 'system' }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Admin invitation cancelled', { invitationId: id, cancelledById });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an invitation token
|
||||||
|
* @param {string} token - Invitation token
|
||||||
|
* @returns {Promise<object|null>} Invitation details if valid
|
||||||
|
*/
|
||||||
|
async function validateInvitationToken(token) {
|
||||||
|
const invitation = await db('admin_invitations')
|
||||||
|
.join('roles', 'roles.id', 'admin_invitations.role_id')
|
||||||
|
.where('admin_invitations.token', token)
|
||||||
|
.whereNull('admin_invitations.accepted_at')
|
||||||
|
.where('admin_invitations.expires_at', '>', new Date())
|
||||||
|
.select(
|
||||||
|
'admin_invitations.email',
|
||||||
|
'admin_invitations.expires_at',
|
||||||
|
'roles.display_name as role_name'
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
return invitation || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createInvitation,
|
||||||
|
acceptInvitation,
|
||||||
|
getAllAdminUsers,
|
||||||
|
getAdminUserById,
|
||||||
|
updateAdminUser,
|
||||||
|
deactivateAdminUser,
|
||||||
|
resetAdminPassword,
|
||||||
|
getAllRoles,
|
||||||
|
getPendingInvitations,
|
||||||
|
cancelInvitation,
|
||||||
|
validateInvitationToken
|
||||||
|
};
|
||||||
+2
-2
@@ -30,8 +30,8 @@ COPY . .
|
|||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage (use Alpine 3.22+ with patched libpng/c-ares)
|
# Production stage (use Alpine with patched libpng/c-ares)
|
||||||
FROM nginx:1.27-alpine3.22
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
# Upgrade all packages to fix security vulnerabilities
|
# Upgrade all packages to fix security vulnerabilities
|
||||||
# This ensures libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
|
# This ensures libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
|
||||||
|
|||||||
Generated
+50
-61
@@ -1088,9 +1088,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/js": {
|
"node_modules/@eslint/js": {
|
||||||
"version": "9.39.1",
|
"version": "9.39.2",
|
||||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
|
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
|
||||||
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
|
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1604,9 +1604,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.11",
|
"version": "5.90.16",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
|
||||||
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
|
"integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1614,12 +1614,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.90.11",
|
"version": "5.90.16",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
|
||||||
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
|
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.90.11"
|
"@tanstack/query-core": "5.90.16"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1688,9 +1688,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/react": {
|
"node_modules/@testing-library/react": {
|
||||||
"version": "16.3.0",
|
"version": "16.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
|
||||||
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
|
"integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2935,9 +2935,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/autoprefixer": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.22",
|
"version": "10.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
|
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2955,10 +2955,9 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"browserslist": "^4.27.0",
|
"browserslist": "^4.28.1",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001760",
|
||||||
"fraction.js": "^5.3.4",
|
"fraction.js": "^5.3.4",
|
||||||
"normalize-range": "^0.1.2",
|
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"postcss-value-parser": "^4.2.0"
|
"postcss-value-parser": "^4.2.0"
|
||||||
},
|
},
|
||||||
@@ -2991,9 +2990,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.31",
|
"version": "2.9.12",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz",
|
||||||
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
|
"integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -3038,9 +3037,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.0",
|
"version": "4.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||||
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
|
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3059,11 +3058,11 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.25",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
"electron-to-chromium": "^1.5.249",
|
"electron-to-chromium": "^1.5.263",
|
||||||
"node-releases": "^2.0.27",
|
"node-releases": "^2.0.27",
|
||||||
"update-browserslist-db": "^1.1.4"
|
"update-browserslist-db": "^1.2.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"browserslist": "cli.js"
|
"browserslist": "cli.js"
|
||||||
@@ -3116,9 +3115,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001757",
|
"version": "1.0.30001762",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
|
||||||
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
|
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -3522,9 +3521,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||||
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
|
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
@@ -3545,9 +3544,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.262",
|
"version": "1.5.267",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
|
||||||
"integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
|
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -3680,9 +3679,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint": {
|
"node_modules/eslint": {
|
||||||
"version": "9.39.1",
|
"version": "9.39.2",
|
||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
"peer": true,
|
||||||
@@ -3693,7 +3692,7 @@
|
|||||||
"@eslint/config-helpers": "^0.4.2",
|
"@eslint/config-helpers": "^0.4.2",
|
||||||
"@eslint/core": "^0.17.0",
|
"@eslint/core": "^0.17.0",
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@eslint/js": "9.39.1",
|
"@eslint/js": "9.39.2",
|
||||||
"@eslint/plugin-kit": "^0.4.1",
|
"@eslint/plugin-kit": "^0.4.1",
|
||||||
"@humanfs/node": "^0.16.6",
|
"@humanfs/node": "^0.16.6",
|
||||||
"@humanwhocodes/module-importer": "^1.0.1",
|
"@humanwhocodes/module-importer": "^1.0.1",
|
||||||
@@ -3754,9 +3753,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-react-refresh": {
|
"node_modules/eslint-plugin-react-refresh": {
|
||||||
"version": "0.4.24",
|
"version": "0.4.26",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
|
||||||
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
|
"integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -4325,9 +4324,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "25.6.3",
|
"version": "25.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
|
||||||
"integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==",
|
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -5022,16 +5021,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/normalize-range": {
|
|
||||||
"version": "0.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
|
|
||||||
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nwsapi": {
|
"node_modules/nwsapi": {
|
||||||
"version": "2.2.22",
|
"version": "2.2.22",
|
||||||
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
|
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
|
||||||
@@ -6517,9 +6506,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.4",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-4
@@ -10,9 +10,9 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
|||||||
import { GalleryPage } from './pages/GalleryPage';
|
import { GalleryPage } from './pages/GalleryPage';
|
||||||
import { PreviewPage } from './pages/gallery/PreviewPage';
|
import { PreviewPage } from './pages/gallery/PreviewPage';
|
||||||
import { LegalPage } from './pages/public/LegalPage';
|
import { LegalPage } from './pages/public/LegalPage';
|
||||||
import {
|
import {
|
||||||
AdminLoginPage,
|
AdminLoginPage,
|
||||||
AdminDashboard,
|
AdminDashboard,
|
||||||
EventsListPage,
|
EventsListPage,
|
||||||
CreateEventPage,
|
CreateEventPage,
|
||||||
EventDetailsPage,
|
EventDetailsPage,
|
||||||
@@ -23,8 +23,10 @@ import {
|
|||||||
BrandingPage,
|
BrandingPage,
|
||||||
SettingsPage,
|
SettingsPage,
|
||||||
BackupManagement,
|
BackupManagement,
|
||||||
CMSPage
|
CMSPage,
|
||||||
|
UserManagementPage
|
||||||
} from './pages/admin';
|
} from './pages/admin';
|
||||||
|
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
||||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||||
@@ -128,10 +130,14 @@ function App() {
|
|||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="backup" element={<BackupManagement />} />
|
<Route path="backup" element={<BackupManagement />} />
|
||||||
<Route path="cms" element={<CMSPage />} />
|
<Route path="cms" element={<CMSPage />} />
|
||||||
|
<Route path="users" element={<UserManagementPage />} />
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Public invitation acceptance page */}
|
||||||
|
<Route path="/invite/:token" element={<AcceptInvitePage />} />
|
||||||
|
|
||||||
{/* Public legal pages */}
|
{/* Public legal pages */}
|
||||||
<Route path="/impressum" element={<LegalPage />} />
|
<Route path="/impressum" element={<LegalPage />} />
|
||||||
<Route path="/datenschutz" element={<LegalPage />} />
|
<Route path="/datenschutz" element={<LegalPage />} />
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { AdminAuthProvider } from '../../contexts';
|
import { AdminAuthProvider, PermissionsProvider } from '../../contexts';
|
||||||
|
|
||||||
export const AdminAuthWrapper: React.FC = () => {
|
export const AdminAuthWrapper: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<AdminAuthProvider>
|
<AdminAuthProvider>
|
||||||
<Outlet />
|
<PermissionsProvider>
|
||||||
|
<Outlet />
|
||||||
|
</PermissionsProvider>
|
||||||
</AdminAuthProvider>
|
</AdminAuthProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Calendar,
|
Calendar,
|
||||||
Mail,
|
Mail,
|
||||||
Archive,
|
Archive,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
Palette,
|
Palette,
|
||||||
FileText,
|
FileText,
|
||||||
HardDrive
|
HardDrive,
|
||||||
|
Users
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { VersionInfo } from './VersionInfo';
|
import { VersionInfo } from './VersionInfo';
|
||||||
|
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||||
|
|
||||||
interface AdminSidebarProps {
|
interface AdminSidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -26,23 +28,32 @@ interface NavItem {
|
|||||||
nameKey: string;
|
nameKey: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
permission?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigation: NavItem[] = [
|
const navigation: NavItem[] = [
|
||||||
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
|
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
|
||||||
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar },
|
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
|
||||||
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive },
|
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
|
||||||
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 },
|
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view' },
|
||||||
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail },
|
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
|
||||||
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette },
|
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
|
||||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings },
|
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||||
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive },
|
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
|
||||||
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText },
|
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
|
||||||
|
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
|
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
|
||||||
|
// Filter navigation items based on permissions
|
||||||
|
const filteredNavigation = navigation.filter(item => {
|
||||||
|
if (!item.permission) return true;
|
||||||
|
return hasPermission(item.permission);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -66,7 +77,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
|
<nav className="flex-1 px-4 py-4 space-y-1 overflow-y-auto min-h-0">
|
||||||
{navigation.map((item) => {
|
{filteredNavigation.map((item) => {
|
||||||
const isActive = location.pathname === item.href ||
|
const isActive = location.pathname === item.href ||
|
||||||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
|
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
|
||||||
|
|
||||||
@@ -90,14 +101,16 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Bottom section - sticky to bottom */}
|
{/* Bottom section - sticky to bottom (only for users with settings.view permission) */}
|
||||||
<div className="flex-shrink-0">
|
{hasPermission('settings.view') && (
|
||||||
{/* Version Info */}
|
<div className="flex-shrink-0">
|
||||||
<VersionInfo />
|
{/* Version Info */}
|
||||||
|
<VersionInfo />
|
||||||
{/* Storage Info */}
|
|
||||||
<StorageInfo />
|
{/* Storage Info */}
|
||||||
</div>
|
<StorageInfo />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -111,14 +124,9 @@ const StorageInfo: React.FC = () => {
|
|||||||
refetchInterval: 60000 // Refresh every minute
|
refetchInterval: 60000 // Refresh every minute
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Don't render anything while loading or if data failed to load
|
||||||
if (!storageInfo) {
|
if (!storageInfo) {
|
||||||
return (
|
return null;
|
||||||
<div className="p-4 border-t border-neutral-200">
|
|
||||||
<div className="bg-neutral-100 rounded-lg p-3">
|
|
||||||
<div className="h-12 animate-pulse bg-neutral-200 rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
|
const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||||
|
|
||||||
|
interface PermissionGateProps {
|
||||||
|
permission?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
requireAll?: boolean;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PermissionGate component that conditionally renders children based on user permissions.
|
||||||
|
*
|
||||||
|
* @param permission - A single permission to check
|
||||||
|
* @param permissions - An array of permissions to check
|
||||||
|
* @param requireAll - If true, requires all permissions (AND logic). If false, requires any permission (OR logic). Default: false
|
||||||
|
* @param fallback - Content to render if permission check fails. Default: null
|
||||||
|
* @param children - Content to render if permission check passes
|
||||||
|
*/
|
||||||
|
export const PermissionGate: React.FC<PermissionGateProps> = ({
|
||||||
|
permission,
|
||||||
|
permissions,
|
||||||
|
requireAll = false,
|
||||||
|
fallback = null,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const { hasPermission, hasAnyPermission, hasAllPermissions, isSuperAdmin } = usePermissions();
|
||||||
|
|
||||||
|
// Super admin bypasses all permission checks
|
||||||
|
if (isSuperAdmin) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check single permission
|
||||||
|
if (permission) {
|
||||||
|
if (hasPermission(permission)) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check multiple permissions
|
||||||
|
if (permissions && permissions.length > 0) {
|
||||||
|
const hasAccess = requireAll
|
||||||
|
? hasAllPermissions(permissions)
|
||||||
|
: hasAnyPermission(permissions);
|
||||||
|
|
||||||
|
if (hasAccess) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
return <>{fallback}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no permissions specified, render children (allow access)
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
PermissionGate.displayName = 'PermissionGate';
|
||||||
@@ -500,13 +500,18 @@ export const RestoreWizard = () => {
|
|||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
|
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
|
||||||
<span className="font-medium">{formatBytes(validationResult.spaceCheck.required)}</span>
|
<span className="font-medium">
|
||||||
|
{validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
|
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
|
||||||
<span className="font-medium">{formatBytes(validationResult.spaceCheck.available)}</span>
|
<span className="font-medium">
|
||||||
|
{validationResult.spaceCheck.availableFormatted ||
|
||||||
|
(validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{!validationResult.spaceCheck.sufficient && (
|
{validationResult.spaceCheck.sufficient === false && (
|
||||||
<p className="text-red-600 text-xs mt-2">
|
<p className="text-red-600 text-xs mt-2">
|
||||||
<AlertCircle className="inline h-3 w-3 mr-1" />
|
<AlertCircle className="inline h-3 w-3 mr-1" />
|
||||||
{t('backup.restore.confirmation.spaceCheck.insufficient')}
|
{t('backup.restore.confirmation.spaceCheck.insufficient')}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { api } from '../config/api';
|
||||||
|
import { useAdminAuth } from './AdminAuthContext';
|
||||||
|
import type { AdminPermissions } from '../types';
|
||||||
|
|
||||||
|
interface PermissionsContextType {
|
||||||
|
permissions: string[];
|
||||||
|
role: { name: string; displayName: string } | null;
|
||||||
|
hasPermission: (permission: string) => boolean;
|
||||||
|
hasAnyPermission: (permissions: string[]) => boolean;
|
||||||
|
hasAllPermissions: (permissions: string[]) => boolean;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PermissionsContext = createContext<PermissionsContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const usePermissions = () => {
|
||||||
|
const context = useContext(PermissionsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('usePermissions must be used within a PermissionsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PermissionsProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PermissionsProvider: React.FC<PermissionsProviderProps> = ({ children }) => {
|
||||||
|
const { isAuthenticated } = useAdminAuth();
|
||||||
|
const [permissions, setPermissions] = useState<string[]>([]);
|
||||||
|
const [role, setRole] = useState<{ name: string; displayName: string } | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
setPermissions([]);
|
||||||
|
setRole(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await api.get<AdminPermissions>('/admin/users/me/permissions');
|
||||||
|
setPermissions(response.data.permissions || []);
|
||||||
|
setRole(response.data.role || null);
|
||||||
|
} catch (error) {
|
||||||
|
// Clear permissions on auth failure
|
||||||
|
setPermissions([]);
|
||||||
|
setRole(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(permission: string): boolean => {
|
||||||
|
// Super admin has all permissions
|
||||||
|
if (role?.name === 'super_admin') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return permissions.includes(permission);
|
||||||
|
},
|
||||||
|
[permissions, role]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAnyPermission = useCallback(
|
||||||
|
(perms: string[]): boolean => {
|
||||||
|
// Super admin has all permissions
|
||||||
|
if (role?.name === 'super_admin') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return perms.some((p) => permissions.includes(p));
|
||||||
|
},
|
||||||
|
[permissions, role]
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasAllPermissions = useCallback(
|
||||||
|
(perms: string[]): boolean => {
|
||||||
|
// Super admin has all permissions
|
||||||
|
if (role?.name === 'super_admin') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return perms.every((p) => permissions.includes(p));
|
||||||
|
},
|
||||||
|
[permissions, role]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSuperAdmin = role?.name === 'super_admin';
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
await fetchPermissions();
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionsContext.Provider
|
||||||
|
value={{
|
||||||
|
permissions,
|
||||||
|
role,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
isSuperAdmin,
|
||||||
|
isLoading,
|
||||||
|
refresh,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PermissionsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { PermissionsContext };
|
||||||
@@ -3,4 +3,5 @@ export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
|
|||||||
export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext';
|
export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext';
|
||||||
export type { ThemeConfig, EventTheme } from './ThemeContext';
|
export type { ThemeConfig, EventTheme } from './ThemeContext';
|
||||||
export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility
|
export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility
|
||||||
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
|
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
|
||||||
|
export { PermissionsProvider, usePermissions, PermissionsContext } from './PermissionsContext';
|
||||||
@@ -48,7 +48,7 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
const { data: fetchedSettings, isLoading, error } = useQuery({
|
const { data: fetchedSettings, isLoading, error } = useQuery({
|
||||||
queryKey: ['image-security-settings'],
|
queryKey: ['image-security-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/api/admin/image-security/settings');
|
const response = await api.get('/admin/image-security/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -66,7 +66,7 @@ export const ImageSecurityTab: React.FC = () => {
|
|||||||
// Save mutation
|
// Save mutation
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (newSettings: ImageSecuritySettings) => {
|
mutationFn: async (newSettings: ImageSecuritySettings) => {
|
||||||
const response = await api.put('/api/admin/image-security/settings', newSettings);
|
const response = await api.put('/admin/image-security/settings', newSettings);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './useSessionTimeout';
|
export * from './useSessionTimeout';
|
||||||
export * from './useOnClickOutside';
|
export * from './useOnClickOutside';
|
||||||
export * from './useLocalizedDate';
|
export * from './useLocalizedDate';
|
||||||
export * from './useLocalizedTimeAgo';
|
export * from './useLocalizedTimeAgo';
|
||||||
|
export * from './usePermission';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { usePermissions } from '../contexts/PermissionsContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check if the current user has a specific permission.
|
||||||
|
*
|
||||||
|
* @param permission - The permission to check
|
||||||
|
* @returns boolean indicating if the user has the permission
|
||||||
|
*/
|
||||||
|
export function usePermission(permission: string): boolean {
|
||||||
|
const { hasPermission } = usePermissions();
|
||||||
|
return hasPermission(permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to check if the current user has any of the specified permissions.
|
||||||
|
*
|
||||||
|
* @param permissions - Array of permissions to check
|
||||||
|
* @returns boolean indicating if the user has any of the permissions
|
||||||
|
*/
|
||||||
|
export function useAnyPermission(permissions: string[]): boolean {
|
||||||
|
const { hasAnyPermission } = usePermissions();
|
||||||
|
return hasAnyPermission(permissions);
|
||||||
|
}
|
||||||
@@ -1,4 +1,78 @@
|
|||||||
{
|
{
|
||||||
|
"userManagement": {
|
||||||
|
"title": "Benutzerverwaltung",
|
||||||
|
"subtitle": "Admin-Benutzer und Einladungen verwalten",
|
||||||
|
"loading": "Lade Benutzer...",
|
||||||
|
"loadError": "Fehler beim Laden der Benutzer. Bitte versuchen Sie es erneut.",
|
||||||
|
"inviteUser": "Benutzer einladen",
|
||||||
|
"createInvitation": "Einladung erstellen",
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"emailPlaceholder": "[email protected]",
|
||||||
|
"role": "Rolle",
|
||||||
|
"selectRole": "Rolle auswählen",
|
||||||
|
"sendInvitation": "Einladung senden",
|
||||||
|
"editUser": "Benutzer bearbeiten",
|
||||||
|
"editingUser": "Bearbeite Benutzer",
|
||||||
|
"saveChanges": "Änderungen speichern",
|
||||||
|
"deactivateUser": "Benutzer deaktivieren",
|
||||||
|
"cancelInvitation": "Einladung abbrechen",
|
||||||
|
"invitationSent": "Einladung erfolgreich gesendet",
|
||||||
|
"invitationError": "Fehler beim Senden der Einladung",
|
||||||
|
"invitationCancelled": "Einladung abgebrochen",
|
||||||
|
"cancelInvitationError": "Fehler beim Abbrechen der Einladung",
|
||||||
|
"userUpdated": "Benutzer erfolgreich aktualisiert",
|
||||||
|
"updateUserError": "Fehler beim Aktualisieren des Benutzers",
|
||||||
|
"userDeactivated": "Benutzer erfolgreich deaktiviert",
|
||||||
|
"deactivateUserError": "Fehler beim Deaktivieren des Benutzers",
|
||||||
|
"noRole": "Keine Rolle",
|
||||||
|
"neverLoggedIn": "Nie angemeldet",
|
||||||
|
"expired": "Abgelaufen",
|
||||||
|
"deactivate": "Deaktivieren",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"tabs": {
|
||||||
|
"users": "Benutzer",
|
||||||
|
"invitations": "Einladungen"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalUsers": "Benutzer gesamt",
|
||||||
|
"activeUsers": "Aktive Benutzer",
|
||||||
|
"pendingInvitations": "Ausstehende Einladungen",
|
||||||
|
"inactiveUsers": "Inaktive Benutzer"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "Aktiv",
|
||||||
|
"inactive": "Inaktiv"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"user": "Benutzer",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"role": "Rolle",
|
||||||
|
"status": "Status",
|
||||||
|
"lastLogin": "Letzte Anmeldung",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"invitedBy": "Eingeladen von",
|
||||||
|
"expires": "Läuft ab"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"emailRequired": "E-Mail ist erforderlich",
|
||||||
|
"emailInvalid": "Ungültiges E-Mail-Format",
|
||||||
|
"roleRequired": "Rolle ist erforderlich"
|
||||||
|
},
|
||||||
|
"searchUsersPlaceholder": "Benutzer suchen...",
|
||||||
|
"searchInvitationsPlaceholder": "Einladungen suchen...",
|
||||||
|
"noUsers": "Keine Benutzer gefunden",
|
||||||
|
"noUsersFound": "Keine Benutzer entsprechen Ihrer Suche",
|
||||||
|
"noInvitations": "Keine ausstehenden Einladungen",
|
||||||
|
"noInvitationsFound": "Keine Einladungen entsprechen Ihrer Suche",
|
||||||
|
"confirmDeactivate": {
|
||||||
|
"title": "Benutzer deaktivieren",
|
||||||
|
"message": "Sind Sie sicher, dass Sie {{name}} deaktivieren möchten? Sie können sich dann nicht mehr anmelden."
|
||||||
|
},
|
||||||
|
"confirmCancelInvitation": {
|
||||||
|
"title": "Einladung abbrechen",
|
||||||
|
"message": "Sind Sie sicher, dass Sie die Einladung für {{email}} abbrechen möchten?"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
"error": "Fehler",
|
"error": "Fehler",
|
||||||
@@ -84,7 +158,8 @@
|
|||||||
"analytics": "Analytik",
|
"analytics": "Analytik",
|
||||||
"emailSettings": "E-Mail-Einstellungen",
|
"emailSettings": "E-Mail-Einstellungen",
|
||||||
"backup": "Backup & Wiederherstellung",
|
"backup": "Backup & Wiederherstellung",
|
||||||
"cmsPages": "CMS-Seiten"
|
"cmsPages": "CMS-Seiten",
|
||||||
|
"users": "Benutzer"
|
||||||
},
|
},
|
||||||
"backup": {
|
"backup": {
|
||||||
"external": {
|
"external": {
|
||||||
@@ -153,6 +228,10 @@
|
|||||||
"title": "Backup nicht konfiguriert",
|
"title": "Backup nicht konfiguriert",
|
||||||
"message": "Bitte konfigurieren Sie die Backup-Einstellungen im Tab \"Konfiguration\", bevor Sie Backups ausführen."
|
"message": "Bitte konfigurieren Sie die Backup-Einstellungen im Tab \"Konfiguration\", bevor Sie Backups ausführen."
|
||||||
},
|
},
|
||||||
|
"actions": {
|
||||||
|
"runBackupNow": "Backup jetzt starten",
|
||||||
|
"running": "Läuft..."
|
||||||
|
},
|
||||||
"coverage": {
|
"coverage": {
|
||||||
"title": "Backup-Abdeckung",
|
"title": "Backup-Abdeckung",
|
||||||
"database": "Datenbank",
|
"database": "Datenbank",
|
||||||
@@ -1282,6 +1361,88 @@
|
|||||||
"admin_logout": "Admin {{actorName}} abgemeldet",
|
"admin_logout": "Admin {{actorName}} abgemeldet",
|
||||||
"system_activity": "Systemaktivität: {{type}}",
|
"system_activity": "Systemaktivität: {{type}}",
|
||||||
"unknown": "Unbekannte Aktivität"
|
"unknown": "Unbekannte Aktivität"
|
||||||
|
},
|
||||||
|
"userManagement": "Benutzerverwaltung",
|
||||||
|
"inviteUser": "Benutzer einladen",
|
||||||
|
"pendingInvitations": "Ausstehende Einladungen",
|
||||||
|
"roles": {
|
||||||
|
"super_admin": "Super-Admin",
|
||||||
|
"admin": "Admin",
|
||||||
|
"editor": "Redakteur",
|
||||||
|
"viewer": "Betrachter"
|
||||||
|
},
|
||||||
|
"userStatus": {
|
||||||
|
"active": "Aktiv",
|
||||||
|
"inactive": "Inaktiv"
|
||||||
|
},
|
||||||
|
"inviteForm": {
|
||||||
|
"email": "E-Mail-Adresse",
|
||||||
|
"role": "Rolle",
|
||||||
|
"send": "Einladung senden"
|
||||||
|
},
|
||||||
|
"acceptInvite": {
|
||||||
|
"title": "Admin-Einladung annehmen",
|
||||||
|
"username": "Benutzernamen wählen",
|
||||||
|
"password": "Passwort erstellen",
|
||||||
|
"submit": "Konto erstellen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"insufficient": "Sie haben keine Berechtigung, diese Aktion auszuführen",
|
||||||
|
"viewOnly": "Nur Ansicht"
|
||||||
|
},
|
||||||
|
"acceptInvitation": {
|
||||||
|
"title": "Einladung annehmen",
|
||||||
|
"subtitle": "Erstellen Sie Ihr Administratorkonto",
|
||||||
|
"validating": "Einladung wird überprüft...",
|
||||||
|
"invalidToken": "Ungültige Einladung",
|
||||||
|
"invalidTokenMessage": "Dieser Einladungslink ist ungültig oder abgelaufen. Bitte kontaktieren Sie Ihren Administrator für eine neue Einladung.",
|
||||||
|
"expiredToken": "Einladung abgelaufen",
|
||||||
|
"expiredTokenMessage": "Diese Einladung ist abgelaufen. Bitte fordern Sie eine neue Einladung von Ihrem Administrator an.",
|
||||||
|
"alreadyUsed": "Einladung bereits verwendet",
|
||||||
|
"alreadyUsedMessage": "Diese Einladung wurde bereits verwendet, um ein Konto zu erstellen.",
|
||||||
|
"invitedAs": "Sie wurden eingeladen als",
|
||||||
|
"expiresAt": "Einladung läuft ab",
|
||||||
|
"usernameLabel": "Benutzername",
|
||||||
|
"usernamePlaceholder": "Wählen Sie einen Benutzernamen",
|
||||||
|
"usernameHelp": "3-50 Zeichen, nur Buchstaben, Zahlen, Unterstriche und Bindestriche",
|
||||||
|
"passwordLabel": "Passwort",
|
||||||
|
"passwordPlaceholder": "Erstellen Sie ein sicheres Passwort",
|
||||||
|
"confirmPasswordLabel": "Passwort bestätigen",
|
||||||
|
"confirmPasswordPlaceholder": "Bestätigen Sie Ihr Passwort",
|
||||||
|
"passwordStrength": "Passwortstärke",
|
||||||
|
"requirements": {
|
||||||
|
"title": "Passwortanforderungen:",
|
||||||
|
"minLength": "Mindestens 12 Zeichen",
|
||||||
|
"uppercase": "Mindestens ein Großbuchstabe",
|
||||||
|
"lowercase": "Mindestens ein Kleinbuchstabe",
|
||||||
|
"number": "Mindestens eine Zahl",
|
||||||
|
"special": "Mindestens ein Sonderzeichen"
|
||||||
|
},
|
||||||
|
"strength": {
|
||||||
|
"weak": "Schwach",
|
||||||
|
"fair": "Mittel",
|
||||||
|
"good": "Gut",
|
||||||
|
"strong": "Stark"
|
||||||
|
},
|
||||||
|
"createAccount": "Konto erstellen",
|
||||||
|
"creating": "Konto wird erstellt...",
|
||||||
|
"success": "Konto erstellt!",
|
||||||
|
"successMessage": "Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt mit Ihren Zugangsdaten anmelden.",
|
||||||
|
"redirecting": "Weiterleitung zur Anmeldung in {{seconds}}...",
|
||||||
|
"goToLogin": "Zur Anmeldung",
|
||||||
|
"errors": {
|
||||||
|
"usernameRequired": "Benutzername ist erforderlich",
|
||||||
|
"usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein",
|
||||||
|
"usernameTooLong": "Benutzername darf maximal 50 Zeichen lang sein",
|
||||||
|
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche enthalten",
|
||||||
|
"passwordRequired": "Passwort ist erforderlich",
|
||||||
|
"passwordTooShort": "Passwort muss mindestens 12 Zeichen lang sein",
|
||||||
|
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
|
||||||
|
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
|
||||||
|
"usernameTaken": "Dieser Benutzername ist bereits vergeben",
|
||||||
|
"emailTaken": "Ein Konto mit dieser E-Mail-Adresse existiert bereits",
|
||||||
|
"genericError": "Konto konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -1,4 +1,78 @@
|
|||||||
{
|
{
|
||||||
|
"userManagement": {
|
||||||
|
"title": "User Management",
|
||||||
|
"subtitle": "Manage admin users and invitations",
|
||||||
|
"loading": "Loading users...",
|
||||||
|
"loadError": "Failed to load users. Please try again.",
|
||||||
|
"inviteUser": "Invite User",
|
||||||
|
"createInvitation": "Create Invitation",
|
||||||
|
"email": "Email Address",
|
||||||
|
"emailPlaceholder": "[email protected]",
|
||||||
|
"role": "Role",
|
||||||
|
"selectRole": "Select a role",
|
||||||
|
"sendInvitation": "Send Invitation",
|
||||||
|
"editUser": "Edit User",
|
||||||
|
"editingUser": "Editing user",
|
||||||
|
"saveChanges": "Save Changes",
|
||||||
|
"deactivateUser": "Deactivate User",
|
||||||
|
"cancelInvitation": "Cancel Invitation",
|
||||||
|
"invitationSent": "Invitation sent successfully",
|
||||||
|
"invitationError": "Failed to send invitation",
|
||||||
|
"invitationCancelled": "Invitation cancelled",
|
||||||
|
"cancelInvitationError": "Failed to cancel invitation",
|
||||||
|
"userUpdated": "User updated successfully",
|
||||||
|
"updateUserError": "Failed to update user",
|
||||||
|
"userDeactivated": "User deactivated successfully",
|
||||||
|
"deactivateUserError": "Failed to deactivate user",
|
||||||
|
"noRole": "No Role",
|
||||||
|
"neverLoggedIn": "Never logged in",
|
||||||
|
"expired": "Expired",
|
||||||
|
"deactivate": "Deactivate",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"tabs": {
|
||||||
|
"users": "Users",
|
||||||
|
"invitations": "Invitations"
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"totalUsers": "Total Users",
|
||||||
|
"activeUsers": "Active Users",
|
||||||
|
"pendingInvitations": "Pending Invitations",
|
||||||
|
"inactiveUsers": "Inactive Users"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive"
|
||||||
|
},
|
||||||
|
"table": {
|
||||||
|
"user": "User",
|
||||||
|
"email": "Email",
|
||||||
|
"role": "Role",
|
||||||
|
"status": "Status",
|
||||||
|
"lastLogin": "Last Login",
|
||||||
|
"actions": "Actions",
|
||||||
|
"invitedBy": "Invited By",
|
||||||
|
"expires": "Expires"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"emailRequired": "Email is required",
|
||||||
|
"emailInvalid": "Invalid email format",
|
||||||
|
"roleRequired": "Role is required"
|
||||||
|
},
|
||||||
|
"searchUsersPlaceholder": "Search users...",
|
||||||
|
"searchInvitationsPlaceholder": "Search invitations...",
|
||||||
|
"noUsers": "No users found",
|
||||||
|
"noUsersFound": "No users match your search",
|
||||||
|
"noInvitations": "No pending invitations",
|
||||||
|
"noInvitationsFound": "No invitations match your search",
|
||||||
|
"confirmDeactivate": {
|
||||||
|
"title": "Deactivate User",
|
||||||
|
"message": "Are you sure you want to deactivate {{name}}? They will no longer be able to log in."
|
||||||
|
},
|
||||||
|
"confirmCancelInvitation": {
|
||||||
|
"title": "Cancel Invitation",
|
||||||
|
"message": "Are you sure you want to cancel the invitation for {{email}}?"
|
||||||
|
}
|
||||||
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
@@ -84,7 +158,8 @@
|
|||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"emailSettings": "Email Settings",
|
"emailSettings": "Email Settings",
|
||||||
"backup": "Backup & Restore",
|
"backup": "Backup & Restore",
|
||||||
"cmsPages": "CMS Pages"
|
"cmsPages": "CMS Pages",
|
||||||
|
"users": "Users"
|
||||||
},
|
},
|
||||||
"archives": {
|
"archives": {
|
||||||
"title": "Archives",
|
"title": "Archives",
|
||||||
@@ -1015,6 +1090,88 @@
|
|||||||
"admin_logout": "Admin {{actorName}} logged out",
|
"admin_logout": "Admin {{actorName}} logged out",
|
||||||
"system_activity": "System activity: {{type}}",
|
"system_activity": "System activity: {{type}}",
|
||||||
"unknown": "Unknown activity"
|
"unknown": "Unknown activity"
|
||||||
|
},
|
||||||
|
"userManagement": "User Management",
|
||||||
|
"inviteUser": "Invite User",
|
||||||
|
"pendingInvitations": "Pending Invitations",
|
||||||
|
"roles": {
|
||||||
|
"super_admin": "Super Admin",
|
||||||
|
"admin": "Admin",
|
||||||
|
"editor": "Editor",
|
||||||
|
"viewer": "Viewer"
|
||||||
|
},
|
||||||
|
"userStatus": {
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive"
|
||||||
|
},
|
||||||
|
"inviteForm": {
|
||||||
|
"email": "Email Address",
|
||||||
|
"role": "Role",
|
||||||
|
"send": "Send Invitation"
|
||||||
|
},
|
||||||
|
"acceptInvite": {
|
||||||
|
"title": "Accept Admin Invitation",
|
||||||
|
"username": "Choose a Username",
|
||||||
|
"password": "Create Password",
|
||||||
|
"submit": "Create Account"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"insufficient": "You don't have permission to perform this action",
|
||||||
|
"viewOnly": "View Only"
|
||||||
|
},
|
||||||
|
"acceptInvitation": {
|
||||||
|
"title": "Accept Invitation",
|
||||||
|
"subtitle": "Create your admin account",
|
||||||
|
"validating": "Validating invitation...",
|
||||||
|
"invalidToken": "Invalid Invitation",
|
||||||
|
"invalidTokenMessage": "This invitation link is invalid or has expired. Please contact your administrator for a new invitation.",
|
||||||
|
"expiredToken": "Invitation Expired",
|
||||||
|
"expiredTokenMessage": "This invitation has expired. Please request a new invitation from your administrator.",
|
||||||
|
"alreadyUsed": "Invitation Already Used",
|
||||||
|
"alreadyUsedMessage": "This invitation has already been used to create an account.",
|
||||||
|
"invitedAs": "You've been invited as",
|
||||||
|
"expiresAt": "Invitation expires",
|
||||||
|
"usernameLabel": "Username",
|
||||||
|
"usernamePlaceholder": "Choose a username",
|
||||||
|
"usernameHelp": "3-50 characters, letters, numbers, underscores, and hyphens only",
|
||||||
|
"passwordLabel": "Password",
|
||||||
|
"passwordPlaceholder": "Create a strong password",
|
||||||
|
"confirmPasswordLabel": "Confirm Password",
|
||||||
|
"confirmPasswordPlaceholder": "Confirm your password",
|
||||||
|
"passwordStrength": "Password strength",
|
||||||
|
"requirements": {
|
||||||
|
"title": "Password requirements:",
|
||||||
|
"minLength": "At least 12 characters",
|
||||||
|
"uppercase": "At least one uppercase letter",
|
||||||
|
"lowercase": "At least one lowercase letter",
|
||||||
|
"number": "At least one number",
|
||||||
|
"special": "At least one special character"
|
||||||
|
},
|
||||||
|
"strength": {
|
||||||
|
"weak": "Weak",
|
||||||
|
"fair": "Fair",
|
||||||
|
"good": "Good",
|
||||||
|
"strong": "Strong"
|
||||||
|
},
|
||||||
|
"createAccount": "Create Account",
|
||||||
|
"creating": "Creating account...",
|
||||||
|
"success": "Account Created!",
|
||||||
|
"successMessage": "Your account has been created successfully. You can now log in with your credentials.",
|
||||||
|
"redirecting": "Redirecting to login in {{seconds}}...",
|
||||||
|
"goToLogin": "Go to Login",
|
||||||
|
"errors": {
|
||||||
|
"usernameRequired": "Username is required",
|
||||||
|
"usernameTooShort": "Username must be at least 3 characters",
|
||||||
|
"usernameTooLong": "Username must be at most 50 characters",
|
||||||
|
"usernameInvalid": "Username can only contain letters, numbers, underscores, and hyphens",
|
||||||
|
"passwordRequired": "Password is required",
|
||||||
|
"passwordTooShort": "Password must be at least 12 characters",
|
||||||
|
"passwordsDoNotMatch": "Passwords do not match",
|
||||||
|
"confirmPasswordRequired": "Please confirm your password",
|
||||||
|
"usernameTaken": "This username is already taken",
|
||||||
|
"emailTaken": "An account with this email already exists",
|
||||||
|
"genericError": "Failed to create account. Please try again."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
@@ -1233,6 +1390,10 @@
|
|||||||
"title": "Backup Not Configured",
|
"title": "Backup Not Configured",
|
||||||
"message": "Please configure backup settings in the Configuration tab before running backups."
|
"message": "Please configure backup settings in the Configuration tab before running backups."
|
||||||
},
|
},
|
||||||
|
"actions": {
|
||||||
|
"runBackupNow": "Run Backup Now",
|
||||||
|
"running": "Running..."
|
||||||
|
},
|
||||||
"coverage": {
|
"coverage": {
|
||||||
"title": "Backup Coverage",
|
"title": "Backup Coverage",
|
||||||
"database": "Database",
|
"database": "Database",
|
||||||
|
|||||||
@@ -0,0 +1,973 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Mail,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Edit,
|
||||||
|
UserX,
|
||||||
|
X,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { parseISO, formatDistanceToNow, isPast } from 'date-fns';
|
||||||
|
|
||||||
|
import { Button, Input, Card, Loading } from '../../components/common';
|
||||||
|
import { userManagementService } from '../../services/userManagement.service';
|
||||||
|
import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
|
||||||
|
|
||||||
|
type TabType = 'users' | 'invitations';
|
||||||
|
|
||||||
|
// Role badge colors
|
||||||
|
const getRoleBadgeColor = (roleName: string): string => {
|
||||||
|
switch (roleName?.toLowerCase()) {
|
||||||
|
case 'super_admin':
|
||||||
|
return 'bg-red-100 text-red-700 border-red-200';
|
||||||
|
case 'admin':
|
||||||
|
return 'bg-blue-100 text-blue-700 border-blue-200';
|
||||||
|
case 'editor':
|
||||||
|
return 'bg-green-100 text-green-700 border-green-200';
|
||||||
|
case 'viewer':
|
||||||
|
default:
|
||||||
|
return 'bg-neutral-100 text-neutral-700 border-neutral-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal component for creating invitations
|
||||||
|
interface CreateInvitationModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (email: string, roleId: number) => void;
|
||||||
|
roles: AdminRole[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateInvitationModal: React.FC<CreateInvitationModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
roles,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [roleId, setRoleId] = useState<number | ''>('');
|
||||||
|
const [errors, setErrors] = useState<{ email?: string; role?: string }>({});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newErrors: { email?: string; role?: string } = {};
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
newErrors.email = t('userManagement.validation.emailRequired');
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
newErrors.email = t('userManagement.validation.emailInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roleId) {
|
||||||
|
newErrors.role = t('userManagement.validation.roleRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(email, roleId as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setEmail('');
|
||||||
|
setRoleId('');
|
||||||
|
setErrors({});
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{t('userManagement.createInvitation')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('userManagement.email')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmail(e.target.value);
|
||||||
|
setErrors((prev) => ({ ...prev, email: undefined }));
|
||||||
|
}}
|
||||||
|
placeholder={t('userManagement.emailPlaceholder')}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('userManagement.role')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={roleId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRoleId(e.target.value ? Number(e.target.value) : '');
|
||||||
|
setErrors((prev) => ({ ...prev, role: undefined }));
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<option value="">{t('userManagement.selectRole')}</option>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.id} value={role.id}>
|
||||||
|
{role.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.role && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.role}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
isLoading={isLoading}
|
||||||
|
leftIcon={<Mail className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('userManagement.sendInvitation')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal component for editing users
|
||||||
|
interface EditUserModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (userId: number, roleId: number) => void;
|
||||||
|
user: AdminUser | null;
|
||||||
|
roles: AdminRole[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditUserModal: React.FC<EditUserModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
user,
|
||||||
|
roles,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [roleId, setRoleId] = useState<number | ''>('');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (user?.roleId) {
|
||||||
|
setRoleId(user.roleId);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!user || !roleId) return;
|
||||||
|
onSubmit(user.id, roleId as number);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setRoleId('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen || !user) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-neutral-900">
|
||||||
|
{t('userManagement.editUser')}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-neutral-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 p-3 bg-neutral-50 rounded-lg">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('userManagement.editingUser')}: <strong>{user.username}</strong>
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-500">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('userManagement.role')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={roleId}
|
||||||
|
onChange={(e) => setRoleId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<option value="">{t('userManagement.selectRole')}</option>
|
||||||
|
{roles.map((role) => (
|
||||||
|
<option key={role.id} value={role.id}>
|
||||||
|
{role.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
isLoading={isLoading}
|
||||||
|
leftIcon={<Edit className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('userManagement.saveChanges')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Confirmation dialog component
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmText: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
variant?: 'danger' | 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmText,
|
||||||
|
isLoading,
|
||||||
|
variant = 'danger',
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-start gap-3 mb-4">
|
||||||
|
<div
|
||||||
|
className={`p-2 rounded-full ${
|
||||||
|
variant === 'danger' ? 'bg-red-100' : 'bg-amber-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle
|
||||||
|
className={`w-5 h-5 ${
|
||||||
|
variant === 'danger' ? 'text-red-600' : 'text-amber-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900">{title}</h2>
|
||||||
|
<p className="text-sm text-neutral-600 mt-1">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onConfirm}
|
||||||
|
isLoading={isLoading}
|
||||||
|
className={
|
||||||
|
variant === 'danger'
|
||||||
|
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserManagementPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('users');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false);
|
||||||
|
const [showEditUserModal, setShowEditUserModal] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||||
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
type: 'deactivate' | 'cancelInvitation';
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const {
|
||||||
|
data: users,
|
||||||
|
isLoading: usersLoading,
|
||||||
|
error: usersError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin-users'],
|
||||||
|
queryFn: userManagementService.getUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: roles,
|
||||||
|
isLoading: rolesLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin-roles'],
|
||||||
|
queryFn: userManagementService.getRoles,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: invitations,
|
||||||
|
isLoading: invitationsLoading,
|
||||||
|
error: invitationsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin-invitations'],
|
||||||
|
queryFn: userManagementService.getInvitations,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const createInvitationMutation = useMutation({
|
||||||
|
mutationFn: ({ email, roleId }: { email: string; roleId: number }) =>
|
||||||
|
userManagementService.createInvitation({ email, role_id: roleId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
|
||||||
|
setShowCreateInvitationModal(false);
|
||||||
|
toast.success(t('userManagement.invitationSent'));
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || t('userManagement.invitationError'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelInvitationMutation = useMutation({
|
||||||
|
mutationFn: userManagementService.cancelInvitation,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
|
||||||
|
setConfirmDialog(null);
|
||||||
|
toast.success(t('userManagement.invitationCancelled'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('userManagement.cancelInvitationError'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserMutation = useMutation({
|
||||||
|
mutationFn: ({ id, roleId }: { id: number; roleId: number }) =>
|
||||||
|
userManagementService.updateUser(id, { roleId }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
setShowEditUserModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
toast.success(t('userManagement.userUpdated'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('userManagement.updateUserError'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deactivateUserMutation = useMutation({
|
||||||
|
mutationFn: userManagementService.deactivateUser,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||||
|
setConfirmDialog(null);
|
||||||
|
toast.success(t('userManagement.userDeactivated'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('userManagement.deactivateUserError'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filtered data
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
if (!users) return [];
|
||||||
|
if (!searchTerm) return users;
|
||||||
|
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return users.filter(
|
||||||
|
(user) =>
|
||||||
|
user.username.toLowerCase().includes(term) ||
|
||||||
|
user.email.toLowerCase().includes(term) ||
|
||||||
|
user.roleName?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}, [users, searchTerm]);
|
||||||
|
|
||||||
|
const filteredInvitations = useMemo(() => {
|
||||||
|
if (!invitations) return [];
|
||||||
|
if (!searchTerm) return invitations;
|
||||||
|
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
return invitations.filter(
|
||||||
|
(invitation) =>
|
||||||
|
invitation.email.toLowerCase().includes(term) ||
|
||||||
|
invitation.roleName?.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
}, [invitations, searchTerm]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleCreateInvitation = (email: string, roleId: number) => {
|
||||||
|
createInvitationMutation.mutate({ email, roleId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUser = (user: AdminUser) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setShowEditUserModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateUser = (userId: number, roleId: number) => {
|
||||||
|
updateUserMutation.mutate({ id: userId, roleId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivateUser = (user: AdminUser) => {
|
||||||
|
setConfirmDialog({
|
||||||
|
isOpen: true,
|
||||||
|
type: 'deactivate',
|
||||||
|
id: user.id,
|
||||||
|
name: user.username,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelInvitation = (invitation: AdminInvitation) => {
|
||||||
|
setConfirmDialog({
|
||||||
|
isOpen: true,
|
||||||
|
type: 'cancelInvitation',
|
||||||
|
id: invitation.id,
|
||||||
|
name: invitation.email,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAction = () => {
|
||||||
|
if (!confirmDialog) return;
|
||||||
|
|
||||||
|
if (confirmDialog.type === 'deactivate') {
|
||||||
|
deactivateUserMutation.mutate(confirmDialog.id);
|
||||||
|
} else if (confirmDialog.type === 'cancelInvitation') {
|
||||||
|
cancelInvitationMutation.mutate(confirmDialog.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
const isLoading = usersLoading || rolesLoading || invitationsLoading;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{t('userManagement.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<Loading size="lg" text={t('userManagement.loading')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (usersError || invitationsError) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{t('userManagement.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-600">{t('userManagement.loadError')}</p>
|
||||||
|
<Button onClick={() => window.location.reload()} className="mt-4">
|
||||||
|
{t('common.tryAgain')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { key: TabType; label: string; count: number }[] = [
|
||||||
|
{ key: 'users', label: t('userManagement.tabs.users'), count: users?.length || 0 },
|
||||||
|
{
|
||||||
|
key: 'invitations',
|
||||||
|
label: t('userManagement.tabs.invitations'),
|
||||||
|
count: invitations?.length || 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{t('userManagement.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<Plus className="w-5 h-5" />}
|
||||||
|
onClick={() => setShowCreateInvitationModal(true)}
|
||||||
|
>
|
||||||
|
{t('userManagement.inviteUser')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('userManagement.stats.totalUsers')}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{users?.length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Users className="w-8 h-8 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('userManagement.stats.activeUsers')}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{users?.filter((u) => u.isActive).length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('userManagement.stats.pendingInvitations')}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{invitations?.length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Mail className="w-8 h-8 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('userManagement.stats.inactiveUsers')}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{users?.filter((u) => !u.isActive).length || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<XCircle className="w-8 h-8 text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="border-b border-neutral-200 mb-6">
|
||||||
|
<nav className="-mb-px flex gap-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'border-primary-600 text-primary-600'
|
||||||
|
: 'border-transparent text-neutral-500 hover:text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? 'bg-primary-100 text-primary-700'
|
||||||
|
: 'bg-neutral-100 text-neutral-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Card padding="sm" className="mb-6">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
activeTab === 'users'
|
||||||
|
? t('userManagement.searchUsersPlaceholder')
|
||||||
|
: t('userManagement.searchInvitationsPlaceholder')
|
||||||
|
}
|
||||||
|
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Users Tab Content */}
|
||||||
|
{activeTab === 'users' && (
|
||||||
|
<Card className="overflow-visible">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.user')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.role')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.lastLogin')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-neutral-200">
|
||||||
|
{filteredUsers.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
|
||||||
|
{searchTerm
|
||||||
|
? t('userManagement.noUsersFound')
|
||||||
|
: t('userManagement.noUsers')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-neutral-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||||
|
<span className="text-primary-700 font-medium text-sm">
|
||||||
|
{user.username.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
{user.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-500">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(
|
||||||
|
user.roleName || ''
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
{user.roleDisplayName || user.roleName || t('userManagement.noRole')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
user.isActive
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-neutral-100 text-neutral-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{user.isActive
|
||||||
|
? t('userManagement.status.active')
|
||||||
|
: t('userManagement.status.inactive')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{user.lastLogin ? (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-neutral-600">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{formatDistanceToNow(parseISO(user.lastLogin), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-neutral-400">
|
||||||
|
{t('userManagement.neverLoggedIn')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditUser(user)}
|
||||||
|
className="p-1.5 text-neutral-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
|
||||||
|
title={t('userManagement.editUser')}
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{user.isActive && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeactivateUser(user)}
|
||||||
|
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('userManagement.deactivateUser')}
|
||||||
|
>
|
||||||
|
<UserX className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invitations Tab Content */}
|
||||||
|
{activeTab === 'invitations' && (
|
||||||
|
<Card className="overflow-visible">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.email')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.role')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.invitedBy')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.expires')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||||
|
{t('userManagement.table.actions')}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-neutral-200">
|
||||||
|
{filteredInvitations.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
|
||||||
|
{searchTerm
|
||||||
|
? t('userManagement.noInvitationsFound')
|
||||||
|
: t('userManagement.noInvitations')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredInvitations.map((invitation) => {
|
||||||
|
const isExpired = isPast(parseISO(invitation.expiresAt));
|
||||||
|
return (
|
||||||
|
<tr key={invitation.id} className="hover:bg-neutral-50">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
|
||||||
|
<Mail className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-neutral-900">
|
||||||
|
{invitation.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(
|
||||||
|
invitation.roleName || ''
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
{invitation.roleName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-neutral-600">
|
||||||
|
{invitation.invitedBy || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 text-sm ${
|
||||||
|
isExpired ? 'text-red-600' : 'text-neutral-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
{isExpired
|
||||||
|
? t('userManagement.expired')
|
||||||
|
: formatDistanceToNow(parseISO(invitation.expiresAt), {
|
||||||
|
addSuffix: true,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelInvitation(invitation)}
|
||||||
|
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
title={t('userManagement.cancelInvitation')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Invitation Modal */}
|
||||||
|
<CreateInvitationModal
|
||||||
|
isOpen={showCreateInvitationModal}
|
||||||
|
onClose={() => setShowCreateInvitationModal(false)}
|
||||||
|
onSubmit={handleCreateInvitation}
|
||||||
|
roles={roles || []}
|
||||||
|
isLoading={createInvitationMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit User Modal */}
|
||||||
|
<EditUserModal
|
||||||
|
isOpen={showEditUserModal}
|
||||||
|
onClose={() => {
|
||||||
|
setShowEditUserModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleUpdateUser}
|
||||||
|
user={selectedUser}
|
||||||
|
roles={roles || []}
|
||||||
|
isLoading={updateUserMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Confirmation Dialog */}
|
||||||
|
{confirmDialog && (
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={confirmDialog.isOpen}
|
||||||
|
onClose={() => setConfirmDialog(null)}
|
||||||
|
onConfirm={handleConfirmAction}
|
||||||
|
title={
|
||||||
|
confirmDialog.type === 'deactivate'
|
||||||
|
? t('userManagement.confirmDeactivate.title')
|
||||||
|
: t('userManagement.confirmCancelInvitation.title')
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
confirmDialog.type === 'deactivate'
|
||||||
|
? t('userManagement.confirmDeactivate.message', { name: confirmDialog.name })
|
||||||
|
: t('userManagement.confirmCancelInvitation.message', {
|
||||||
|
email: confirmDialog.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
confirmText={
|
||||||
|
confirmDialog.type === 'deactivate'
|
||||||
|
? t('userManagement.deactivate')
|
||||||
|
: t('userManagement.cancel')
|
||||||
|
}
|
||||||
|
isLoading={
|
||||||
|
confirmDialog.type === 'deactivate'
|
||||||
|
? deactivateUserMutation.isPending
|
||||||
|
: cancelInvitationMutation.isPending
|
||||||
|
}
|
||||||
|
variant={confirmDialog.type === 'deactivate' ? 'danger' : 'warning'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UserManagementPage.displayName = 'UserManagementPage';
|
||||||
@@ -10,4 +10,5 @@ export { BrandingPage } from './BrandingPage';
|
|||||||
export { SettingsPage } from './SettingsPage';
|
export { SettingsPage } from './SettingsPage';
|
||||||
export { CMSPage } from './CMSPage';
|
export { CMSPage } from './CMSPage';
|
||||||
export { BackupManagement } from './BackupManagement';
|
export { BackupManagement } from './BackupManagement';
|
||||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||||
|
export { UserManagementPage } from './UserManagementPage';
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Lock,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Mail,
|
||||||
|
Shield,
|
||||||
|
XCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
import { Button, Input, Card, Loading } from '../../components/common';
|
||||||
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
|
interface InvitationValidation {
|
||||||
|
valid: boolean;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcceptInvitePayload {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcceptInviteResponse {
|
||||||
|
message: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PasswordRequirement {
|
||||||
|
label: string;
|
||||||
|
met: boolean;
|
||||||
|
test: (password: string) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AcceptInvitePage: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Validate invitation token
|
||||||
|
const {
|
||||||
|
data: invitation,
|
||||||
|
isLoading: isValidating,
|
||||||
|
error: validationError,
|
||||||
|
isError
|
||||||
|
} = useQuery<InvitationValidation>({
|
||||||
|
queryKey: ['invitation', token],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await api.get(`/invite/${token}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
enabled: !!token,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accept invitation mutation
|
||||||
|
const acceptMutation = useMutation({
|
||||||
|
mutationFn: async (payload: AcceptInvitePayload) => {
|
||||||
|
const response = await api.post<AcceptInviteResponse>(`/invite/${token}`, payload);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
toast.success(data.message || t('acceptInvitation.success'));
|
||||||
|
setRedirectCountdown(5);
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const errorMessage = error.response?.data?.error || error.response?.data?.message;
|
||||||
|
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
// Validation errors
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
const validationErrors: Record<string, string> = {};
|
||||||
|
error.response.data.errors.forEach((err: { field: string; message: string }) => {
|
||||||
|
validationErrors[err.field] = err.message;
|
||||||
|
});
|
||||||
|
setErrors(validationErrors);
|
||||||
|
} else {
|
||||||
|
toast.error(errorMessage || t('acceptInvitation.validationError'));
|
||||||
|
}
|
||||||
|
} else if (error.response?.status === 422) {
|
||||||
|
toast.error(errorMessage || t('acceptInvitation.validationError'));
|
||||||
|
} else if (error.response?.status === 404) {
|
||||||
|
toast.error(t('acceptInvitation.invalidOrExpired'));
|
||||||
|
} else if (error.response?.status === 409) {
|
||||||
|
toast.error(errorMessage || t('acceptInvitation.alreadyUsed'));
|
||||||
|
} else {
|
||||||
|
toast.error(t('acceptInvitation.generalError'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Password requirements
|
||||||
|
const passwordRequirements: PasswordRequirement[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
label: t('acceptInvitation.requirements.minLength'),
|
||||||
|
met: false,
|
||||||
|
test: (pwd: string) => pwd.length >= 8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('acceptInvitation.requirements.uppercase'),
|
||||||
|
met: false,
|
||||||
|
test: (pwd: string) => /[A-Z]/.test(pwd),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('acceptInvitation.requirements.lowercase'),
|
||||||
|
met: false,
|
||||||
|
test: (pwd: string) => /[a-z]/.test(pwd),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('acceptInvitation.requirements.number'),
|
||||||
|
met: false,
|
||||||
|
test: (pwd: string) => /[0-9]/.test(pwd),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('acceptInvitation.requirements.special'),
|
||||||
|
met: false,
|
||||||
|
test: (pwd: string) => /[!@#$%^&*(),.?":{}|<>]/.test(pwd),
|
||||||
|
},
|
||||||
|
], [t]);
|
||||||
|
|
||||||
|
// Calculate password strength
|
||||||
|
const passwordStrength = useMemo(() => {
|
||||||
|
const metCount = passwordRequirements.filter(req => req.test(formData.password)).length;
|
||||||
|
if (metCount === 0) return { level: 0, label: '', color: '' };
|
||||||
|
if (metCount <= 2) return { level: 1, label: t('acceptInvitation.strength.weak'), color: 'bg-red-500' };
|
||||||
|
if (metCount <= 3) return { level: 2, label: t('acceptInvitation.strength.fair'), color: 'bg-yellow-500' };
|
||||||
|
if (metCount <= 4) return { level: 3, label: t('acceptInvitation.strength.good'), color: 'bg-blue-500' };
|
||||||
|
return { level: 4, label: t('acceptInvitation.strength.strong'), color: 'bg-green-500' };
|
||||||
|
}, [formData.password, passwordRequirements, t]);
|
||||||
|
|
||||||
|
// Redirect countdown effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (redirectCountdown === null) return;
|
||||||
|
|
||||||
|
if (redirectCountdown === 0) {
|
||||||
|
navigate('/admin/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setRedirectCountdown(prev => (prev !== null ? prev - 1 : null));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [redirectCountdown, navigate]);
|
||||||
|
|
||||||
|
// Validate username
|
||||||
|
const validateUsername = (username: string): string | null => {
|
||||||
|
if (!username) {
|
||||||
|
return t('acceptInvitation.usernameRequired');
|
||||||
|
}
|
||||||
|
if (username.length < 3) {
|
||||||
|
return t('acceptInvitation.usernameTooShort');
|
||||||
|
}
|
||||||
|
if (username.length > 50) {
|
||||||
|
return t('acceptInvitation.usernameTooLong');
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||||
|
return t('acceptInvitation.usernameInvalid');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
const usernameError = validateUsername(formData.username);
|
||||||
|
if (usernameError) {
|
||||||
|
newErrors.username = usernameError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
newErrors.password = t('acceptInvitation.passwordRequired');
|
||||||
|
} else {
|
||||||
|
const allRequirementsMet = passwordRequirements.every(req => req.test(formData.password));
|
||||||
|
if (!allRequirementsMet) {
|
||||||
|
newErrors.password = t('acceptInvitation.passwordRequirements');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.confirmPassword) {
|
||||||
|
newErrors.confirmPassword = t('acceptInvitation.confirmPasswordRequired');
|
||||||
|
} else if (formData.password !== formData.confirmPassword) {
|
||||||
|
newErrors.confirmPassword = t('acceptInvitation.passwordsDoNotMatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
acceptMutation.mutate({
|
||||||
|
username: formData.username,
|
||||||
|
password: formData.password,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (field: string) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => ({ ...prev, [field]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format role for display
|
||||||
|
const formatRole = (role: string): string => {
|
||||||
|
const roleKey = `admin.roles.${role}`;
|
||||||
|
const translated = t(roleKey);
|
||||||
|
// If translation not found, format the role nicely
|
||||||
|
if (translated === roleKey) {
|
||||||
|
return role.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||||
|
}
|
||||||
|
return translated;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format expiration date
|
||||||
|
const formatExpirationDate = (dateString: string): string => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isValidating) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
|
<div className="w-full max-w-md text-center">
|
||||||
|
<Loading size="lg" text={t('acceptInvitation.validating')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state - invalid or expired token
|
||||||
|
if (isError || !invitation?.valid) {
|
||||||
|
const errorMessage = (validationError as any)?.response?.data?.error || t('acceptInvitation.invalidOrExpired');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<Card padding="lg">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-red-100 flex items-center justify-center">
|
||||||
|
<XCircle className="w-8 h-8 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 mb-2">
|
||||||
|
{t('acceptInvitation.invalidTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 mb-6">
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-500 mb-6">
|
||||||
|
{t('acceptInvitation.contactAdmin')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => navigate('/admin/login')}
|
||||||
|
>
|
||||||
|
{t('acceptInvitation.goToLogin')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state - account created
|
||||||
|
if (acceptMutation.isSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<Card padding="lg">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-6 rounded-full bg-green-100 flex items-center justify-center">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 mb-2">
|
||||||
|
{t('acceptInvitation.successTitle')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-neutral-600 mb-6">
|
||||||
|
{t('acceptInvitation.successMessage')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-500 mb-6">
|
||||||
|
{t('acceptInvitation.redirecting', { seconds: redirectCountdown })}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => navigate('/admin/login')}
|
||||||
|
>
|
||||||
|
{t('acceptInvitation.goToLoginNow')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form state - valid invitation
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div
|
||||||
|
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: '#eee6d2' }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/picpeak-logo-transparent.png"
|
||||||
|
alt="PicPeak"
|
||||||
|
className="w-[180px] h-[130px] object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>
|
||||||
|
{t('acceptInvitation.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||||
|
{t('acceptInvitation.subtitle')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invitation Info Card */}
|
||||||
|
<Card padding="md" className="mb-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Mail className="w-5 h-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-neutral-500">{t('acceptInvitation.invitedAs')}</p>
|
||||||
|
<p className="font-medium text-neutral-900 truncate">{invitation.email}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<Shield className="w-3 h-3" />
|
||||||
|
{formatRole(invitation.role)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 mt-2">
|
||||||
|
{t('acceptInvitation.expiresAt', { date: formatExpirationDate(invitation.expiresAt) })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Registration Form */}
|
||||||
|
<Card padding="lg">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Form Error */}
|
||||||
|
{errors.form && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-red-800">{errors.form}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Username Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('acceptInvitation.usernameLabel')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleInputChange('username')}
|
||||||
|
error={errors.username}
|
||||||
|
placeholder={t('acceptInvitation.usernamePlaceholder')}
|
||||||
|
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
{t('acceptInvitation.usernameHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('acceptInvitation.passwordLabel')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange('password')}
|
||||||
|
error={errors.password}
|
||||||
|
placeholder={t('acceptInvitation.passwordPlaceholder')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Strength Indicator */}
|
||||||
|
{formData.password && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs text-neutral-500">{t('acceptInvitation.passwordStrength')}</span>
|
||||||
|
<span className={`text-xs font-medium ${
|
||||||
|
passwordStrength.level <= 1 ? 'text-red-600' :
|
||||||
|
passwordStrength.level === 2 ? 'text-yellow-600' :
|
||||||
|
passwordStrength.level === 3 ? 'text-blue-600' :
|
||||||
|
'text-green-600'
|
||||||
|
}`}>
|
||||||
|
{passwordStrength.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 bg-neutral-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${passwordStrength.color}`}
|
||||||
|
style={{ width: `${(passwordStrength.level / 4) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Password Requirements */}
|
||||||
|
<div className="mt-3 space-y-1.5">
|
||||||
|
<p className="text-xs font-medium text-neutral-600">{t('acceptInvitation.requirementsTitle')}</p>
|
||||||
|
{passwordRequirements.map((req, index) => {
|
||||||
|
const isMet = req.test(formData.password);
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
{isMet ? (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<div className="w-3.5 h-3.5 rounded-full border border-neutral-300" />
|
||||||
|
)}
|
||||||
|
<span className={`text-xs ${isMet ? 'text-green-700' : 'text-neutral-500'}`}>
|
||||||
|
{req.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('acceptInvitation.confirmPasswordLabel')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleInputChange('confirmPassword')}
|
||||||
|
error={errors.confirmPassword}
|
||||||
|
placeholder={t('acceptInvitation.confirmPasswordPlaceholder')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formData.confirmPassword && formData.password === formData.confirmPassword && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-1.5">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-500" />
|
||||||
|
<span className="text-xs text-green-700">{t('acceptInvitation.passwordsMatch')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
isLoading={acceptMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t('acceptInvitation.createAccount')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="text-center mt-8">
|
||||||
|
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||||
|
{t('acceptInvitation.alreadyHaveAccount')}{' '}
|
||||||
|
<a
|
||||||
|
href="/admin/login"
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: 'var(--color-primary, #5C8762)' }}
|
||||||
|
>
|
||||||
|
{t('acceptInvitation.signIn')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
|
||||||
|
{t('adminLogin.poweredBy')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
AcceptInvitePage.displayName = 'AcceptInvitePage';
|
||||||
@@ -8,4 +8,5 @@ export { emailService } from './email.service';
|
|||||||
export { settingsService } from './settings.service';
|
export { settingsService } from './settings.service';
|
||||||
export { cmsService } from './cms.service';
|
export { cmsService } from './cms.service';
|
||||||
export { notificationsService } from './notifications.service';
|
export { notificationsService } from './notifications.service';
|
||||||
export { feedbackService } from './feedback.service';
|
export { feedbackService } from './feedback.service';
|
||||||
|
export { userManagementService } from './userManagement.service';
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { api } from '../config/api';
|
||||||
|
import type { AdminUser, AdminRole, AdminInvitation } from '../types';
|
||||||
|
|
||||||
|
// Transform snake_case API response to camelCase for frontend
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function transformUser(user: any): AdminUser {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
isActive: user.isActive ?? user.is_active,
|
||||||
|
lastLogin: user.lastLogin ?? user.last_login,
|
||||||
|
lastLoginIp: user.lastLoginIp ?? user.last_login_ip,
|
||||||
|
createdAt: user.createdAt ?? user.created_at,
|
||||||
|
updatedAt: user.updatedAt ?? user.updated_at,
|
||||||
|
roleId: user.roleId ?? user.role_id,
|
||||||
|
roleName: user.roleName ?? user.role_name,
|
||||||
|
roleDisplayName: user.roleDisplayName ?? user.role_display_name,
|
||||||
|
createdByUsername: user.createdByUsername ?? user.created_by_username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
function transformRole(role: any): AdminRole {
|
||||||
|
return {
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
displayName: role.displayName ?? role.display_name,
|
||||||
|
description: role.description,
|
||||||
|
isSystem: role.isSystem ?? role.is_system,
|
||||||
|
priority: role.priority,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetUsersResponse {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
users: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetUserResponse {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
user: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetRolesResponse {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
roles: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetInvitationsResponse {
|
||||||
|
invitations: AdminInvitation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateInvitationData {
|
||||||
|
email: string;
|
||||||
|
role_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateInvitationResponse {
|
||||||
|
invitation: AdminInvitation;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateUserData {
|
||||||
|
roleId?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateUserResponse {
|
||||||
|
user: AdminUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeactivateUserResponse {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResetPasswordResponse {
|
||||||
|
temporaryPassword: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidateInvitationResponse {
|
||||||
|
valid: boolean;
|
||||||
|
email: string;
|
||||||
|
roleName: string;
|
||||||
|
invitedBy: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcceptInvitationData {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcceptInvitationResponse {
|
||||||
|
message: string;
|
||||||
|
user: AdminUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userManagementService = {
|
||||||
|
/**
|
||||||
|
* Get all admin users
|
||||||
|
*/
|
||||||
|
async getUsers(): Promise<AdminUser[]> {
|
||||||
|
const response = await api.get<GetUsersResponse>('/admin/users');
|
||||||
|
return response.data.users.map(transformUser);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single admin user by ID
|
||||||
|
*/
|
||||||
|
async getUser(id: number): Promise<AdminUser> {
|
||||||
|
const response = await api.get<GetUserResponse>(`/admin/users/${id}`);
|
||||||
|
return transformUser(response.data.user);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available roles
|
||||||
|
*/
|
||||||
|
async getRoles(): Promise<AdminRole[]> {
|
||||||
|
const response = await api.get<GetRolesResponse>('/admin/users/roles');
|
||||||
|
return response.data.roles.map(transformRole);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all pending invitations
|
||||||
|
*/
|
||||||
|
async getInvitations(): Promise<AdminInvitation[]> {
|
||||||
|
const response = await api.get<GetInvitationsResponse>('/admin/users/invitations');
|
||||||
|
return response.data.invitations;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new invitation
|
||||||
|
*/
|
||||||
|
async createInvitation(data: CreateInvitationData): Promise<AdminInvitation> {
|
||||||
|
const response = await api.post<CreateInvitationResponse>('/admin/users/invite', data);
|
||||||
|
return response.data.invitation;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel a pending invitation
|
||||||
|
*/
|
||||||
|
async cancelInvitation(id: number): Promise<void> {
|
||||||
|
await api.delete(`/admin/users/invitations/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an admin user
|
||||||
|
*/
|
||||||
|
async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> {
|
||||||
|
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, data);
|
||||||
|
return transformUser(response.data.user);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate an admin user
|
||||||
|
*/
|
||||||
|
async deactivateUser(id: number): Promise<string> {
|
||||||
|
const response = await api.post<DeactivateUserResponse>(`/admin/users/${id}/deactivate`);
|
||||||
|
return response.data.message;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset an admin user's password
|
||||||
|
*/
|
||||||
|
async resetPassword(id: number): Promise<ResetPasswordResponse> {
|
||||||
|
const response = await api.post<ResetPasswordResponse>(`/admin/users/${id}/reset-password`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an invitation token (public endpoint)
|
||||||
|
*/
|
||||||
|
async validateInvitation(token: string): Promise<ValidateInvitationResponse> {
|
||||||
|
const response = await api.get<ValidateInvitationResponse>(`/invite/${token}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept an invitation and create account (public endpoint)
|
||||||
|
*/
|
||||||
|
async acceptInvitation(token: string, data: AcceptInvitationData): Promise<AcceptInvitationResponse> {
|
||||||
|
const response = await api.post<AcceptInvitationResponse>(`/invite/${token}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -144,6 +144,18 @@ export interface AdminUser {
|
|||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
mustChangePassword?: boolean;
|
mustChangePassword?: boolean;
|
||||||
|
role?: {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
roleId?: number;
|
||||||
|
roleName?: string;
|
||||||
|
roleDisplayName?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
lastLogin?: string | null;
|
||||||
|
lastLoginIp?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
createdByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
@@ -178,5 +190,32 @@ export interface ApiError {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role and Permission types
|
||||||
|
export interface AdminRole {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
isSystem?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPermissions {
|
||||||
|
role: {
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
} | null;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminInvitation {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
roleName: string;
|
||||||
|
invitedBy: string;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Export protection types
|
// Export protection types
|
||||||
export * from './protection';
|
export * from './protection';
|
||||||
|
|||||||
Reference in New Issue
Block a user