Compare commits

..

35 Commits

Author SHA1 Message Date
Paul Nothaft 29dc2a3cf1 Merge pull request #89 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.2.2
2026-01-08 15:53:44 +01:00
github-actions[bot] d2cba449b0 chore(main): release 2.2.2 2026-01-08 14:51:24 +00:00
Paul Nothaft e0bd19a74d fix: Align nginx backend port for production Docker deployments (v2.2.2) (#88)
Fix 502 Bad Gateway on root path in production Docker/Traefik deployments.

  - nginx.conf: backend:3001 → backend:3000 (matches production container port)
  - docker-compose.yml: align dev environment to use port 3000
  - Bump version to 2.2.2
2026-01-08 15:51:10 +01:00
Paul Nothaft 0ab8cbde7f chore: bump version to 2.2.2
Includes fix for nginx backend port alignment (3001 → 3000) that caused
502 errors on root path in production Docker deployments.
2026-01-08 15:46:41 +01:00
Paul Nothaft 3a8d53f492 fix: align backend port to 3000 across all configurations
The production docker-compose used port 3000 internally but nginx.conf
was hardcoded to port 3001, causing 502 errors on the root path (/).

Changes:
- Update nginx.conf to use backend:3000
- Update docker-compose.yml to use PORT=3000 for consistency
- Update port mapping and healthcheck to use port 3000
2026-01-08 15:28:35 +01:00
Paul Nothaft 804a964ba0 Merge pull request #87 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.2.1
2026-01-08 14:01:58 +01:00
github-actions[bot] d2cd1aa933 chore(main): release 2.2.1 2026-01-08 13:01:36 +00:00
Paul Nothaft d7ecf83d32 fix: Resolve branding display issues and invitation parsing errors (v2.2.1) (#86)
Fixes #84, Fixes #85
  - Fix uploads proxy routing in nginx and vite dev server
  - Fix logo/favicon state handling in BrandingPage
  - Fix invitation API response field transformation (snake_case → camelCase)  
  - Add hide_powered_by to public settings API
  - Mark Multiple Administrators as implemented in roadmap
  - Bump version to 2.2.1
2026-01-08 14:01:21 +01:00
Paul Nothaft ebb2ce6065 Merge branch 'main' into feature/multiple-administrators 2026-01-08 13:58:41 +01:00
Paul Nothaft 1931d73b60 fix: resolve branding display issues and invitation parsing errors
Fixes #84 - Logo and favicon not displaying on branding page and galleries
Fixes #85 - Invitations showing undefined expiresAt causing parseISO errors

Changes:
- Fix nginx.conf: Add ^~ modifier to /uploads location to prioritize proxy over static file matching
- Fix vite.config.ts: Add /uploads proxy for development environment
- Fix BrandingPage.tsx: Include logo_url from branding settings instead of expecting it from theme
- Fix adminUsers.js: Add transformInvitation() to convert snake_case DB fields to camelCase API response
- Fix publicSettings.js: Add branding_hide_powered_by to public settings API response
- Update README.md: Mark Multiple Administrators feature as implemented
- Bump version to 2.2.1
2026-01-08 13:56:02 +01:00
Paul Nothaft 0d5ce48dcc fix: handle legacy non-JSON logo paths when replacing logo
When uploading a new logo, the code tries to delete the old logo file.
This failed when the old path was stored as a raw path (legacy format)
instead of JSON-serialized. Added check to handle both formats.
2026-01-08 11:44:29 +01:00
Paul Nothaft 4872ef71f8 ci: only build ARM64 images for tagged releases
QEMU emulation of ARM64 on x86 GitHub runners is too slow and
unreliable for npm operations, causing builds to hang or crash
with "Illegal instruction" errors.

Changed platform detection logic to:
- Tagged releases (v*.*.*): Build both amd64 and arm64
- All other builds (branches, PRs): Build amd64 only

This ensures fast CI feedback during development while still
providing multi-arch images for production releases.
2026-01-08 11:33:20 +01:00
Paul Nothaft b83f4272b5 fix: JSON serialize favicon and logo URLs for PostgreSQL storage
Fixes #84

The favicon and logo upload endpoints were storing URL paths directly
without JSON.stringify(), causing PostgreSQL JSON validation errors
("Token '/' is invalid") since paths like "/uploads/favicons/..."
are not valid JSON.

Applied JSON.stringify() to:
- branding_logo_url setting (lines 358, 364)
- branding_favicon_url setting (lines 894, 900)
2026-01-08 11:29:38 +01:00
github-actions[bot] 5df64992c4 chore: sync package.json versions to 2.2.0 2026-01-08 09:28:03 +00:00
Paul Nothaft 7e5e004270 Merge pull request #83 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.2.0
2026-01-08 10:27:40 +01:00
github-actions[bot] 09ce2b80d0 chore(main): release 2.2.0 2026-01-08 09:26:48 +00:00
Paul Nothaft 476fcce13f fix: Add settings translations and fix manual backup process (#82)
- Add i18n translations for settings tabs (Events, Image Security, Moderation, CSS)
  - Fix manual backup when automated backups are disabled
  - Fix PostgreSQL wait-for-db.sh connection check
2026-01-08 10:26:34 +01:00
Paul Nothaft c030e87213 feat(i18n): add translations for settings tabs
- Add settings.events.* keys for Event Creation settings
- Add settings.imageSecurity.* keys for Image Protection settings
- Add settings.moderation.* keys for Word Filter/Moderation settings
- Add cssTemplates.* keys for Custom CSS Templates
- All settings tabs now have proper i18n support
2026-01-07 22:43:36 +01:00
Paul Nothaft e6dd89e969 fix(backup): allow manual backups when automated backups are disabled
- Manual backup button now works regardless of backup_enabled setting
- backup_enabled only controls scheduled/automated backups
- Manual backups only require destination to be configured
- Fixed backup_type to correctly show 'manual' vs 'scheduled'
2026-01-07 22:39:57 +01:00
Paul Nothaft e85a68a386 fix(db): improve PostgreSQL connection check in wait-for-db.sh
- Try connecting to target database first (most common case)
- Fall back to template1 instead of postgres database for checks
- The picpeak user may not have access to postgres system database
- Add better retry logic with max attempts
- Improve error messages
2026-01-07 22:33:40 +01:00
github-actions[bot] 0acce6ab08 chore: sync package.json versions to 2.1.1 2026-01-07 21:24:07 +00:00
Paul Nothaft 92a1c7a2df Merge pull request #81 from the-luap/release-please--branches--main
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (push) Has started running
chore(main): release 2.1.1
2026-01-07 22:23:40 +01:00
github-actions[bot] edc57bfbbe chore(main): release 2.1.1 2026-01-07 21:23:23 +00:00
Paul Nothaft 37d4e1cb61 fix: Multi-administrator RBAC, CSS templates & security hardening (#80)
- Add multi-administrator support with role-based access control (RBAC)
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing page
  - Fix photo category selection during upload (#77)
  - Fix category changes not persisting (#77)
  - Improve feedback button visibility in gallery views (#77)
  - Security hardening: upgrade Alpine base image, fix CVEs
  - Add Release Please for automated versioning
  - Fix Docker multi-arch builds with proper QEMU setup
  - Add Photo and Settings service layers
  - Fix date parsing and Vite proxy configuration
  - Fix S3 backup/restore functionality
2026-01-07 22:23:10 +01:00
Paul Nothaft 0d36a273bb fix(ci): add QEMU setup for multi-arch builds and skip for PRs
- Add docker/setup-qemu-action for proper ARM64 emulation
- Skip QEMU setup for PR builds (amd64 only)
- Fix QEMU "Illegal instruction" errors during npm ci
2026-01-07 22:17:33 +01:00
github-actions[bot] a19e218e40 chore: sync package.json versions to 2.1.0 2026-01-07 20:54:46 +00:00
Paul Nothaft 61c53fb24e Merge pull request #79 from the-luap/release-please--branches--main
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (push) Has started running
Build and Push Docker Images / build-frontend (push) Failing after 5m35s
chore(main): release 2.1.0
2026-01-07 21:54:20 +01:00
github-actions[bot] dc8cf9a9a2 chore(main): release 2.1.0 2026-01-07 20:53:40 +00:00
Paul Nothaft 16b3ab039a feat: Multi-administrator RBAC, CSS templates & security hardening (#78)
- Add multi-administrator support with role-based access control
  - Add CSS template system with Apple Liquid Glass designs
  - Add CSS template selector to event editing
  - Fix photo category selection and feedback button visibility (#77)
  - Security hardening and Alpine base image upgrade
2026-01-07 21:53:25 +01:00
Paul Nothaft 6a6c2cd34d feat(events): add CSS template selector to event edit page
- Add CSS template selector to ThemeCustomizerEnhanced component
- Rename "Custom CSS" to "Event-specific Custom CSS" for clarity
- Load and save css_template_id when editing events
- Fetch CSS templates when entering edit mode on EventDetailsPage
- Pass CSS template props to ThemeEditorModal
- Add backend validation for css_template_id field
2026-01-07 18:04:23 +01:00
Paul Nothaft 856d53343c fix(photos): resolve upload category selection and improve feedback buttons (#77)
- Fix upload category selection by looking up category from database
  and saving category_id to photos table (was being ignored before)
- Use category slug for filename generation during upload
- Improve Like/Comment button visibility in CarouselGalleryLayout and
  PhotoLightbox with semi-transparent background and border styling
2026-01-07 17:46:46 +01:00
Paul Nothaft d9da98c355 fix(photos): category changes now persist and display correctly (#77)
- Backend PATCH /photos/:photoId now returns updated photo object
- Photo listing now joins with photo_categories table to get actual
  category name and slug instead of hardcoding based on photo.type
- Frontend service now properly returns AdminPhoto from update response

Fixes #77
2026-01-07 17:31:19 +01:00
Paul Nothaft 892e47d017 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)
2026-01-07 17:10:46 +01:00
github-actions[bot] 007e46edb9 chore: sync package.json versions to 2.0.0 2026-01-03 22:57:16 +00:00
Paul Nothaft b706eeb5d3 fix(security): upgrade Alpine base image to fix libpng and c-ares CVEs
Update frontend Dockerfile to use nginx:1.27-alpine3.22 which includes:
- libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
- c-ares >= 1.34.5 (fixes CVE-2025-31498)

Remove redundant edge repository pull since Alpine 3.22 packages are already patched.
2026-01-03 23:52:38 +01:00
82 changed files with 5282 additions and 589 deletions
+26 -14
View File
@@ -45,18 +45,24 @@ jobs:
- name: Determine build platforms
id: platforms
run: |
# For PRs, build only amd64 to avoid QEMU emulation issues with Sharp
# For main/develop/tags, build multi-arch
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
else
# Only build ARM64 for tagged releases (v*.*.*)
# QEMU emulation is too slow/unreliable for npm operations on regular builds
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "skip_qemu=false" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
if: steps.platforms.outputs.skip_qemu != 'true'
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: ${{ steps.platforms.outputs.platforms }}
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
@@ -136,18 +142,24 @@ jobs:
- name: Determine build platforms
id: platforms
run: |
# For PRs, build only amd64 to avoid QEMU emulation issues
# For main/develop/tags, build multi-arch
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
else
# Only build ARM64 for tagged releases (v*.*.*)
# QEMU emulation is too slow/unreliable for npm operations on regular builds
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
echo "skip_qemu=false" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
echo "skip_qemu=true" >> $GITHUB_OUTPUT
fi
- name: Set up QEMU
if: steps.platforms.outputs.skip_qemu != 'true'
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: ${{ steps.platforms.outputs.platforms }}
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
+5
View File
@@ -81,9 +81,14 @@ CLAUDE.md
BUGS_AND_FEATURES.md
frontend/TEST_PLAN.md
docs/REFACTORING_PLAN.md
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
docs/*_PLAN.md
docs/test-*.md
docs/feature-*.md
# Local backup directory (from testing)
backup/
# Local artifacts from browser tooling
.playwright-mcp/
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.0.0"
".": "2.2.2"
}
+55
View File
@@ -5,6 +5,61 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [2.2.2](https://github.com/the-luap/picpeak/compare/v2.2.1...v2.2.2) (2026-01-08)
### Bug Fixes
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
## [2.2.1](https://github.com/the-luap/picpeak/compare/v2.2.0...v2.2.1) (2026-01-08)
### Bug Fixes
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
## [2.2.0](https://github.com/the-luap/picpeak/compare/v2.1.1...v2.2.0) (2026-01-08)
### Features
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
### Bug Fixes
* Add settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
## [2.1.1](https://github.com/the-luap/picpeak/compare/v2.1.0...v2.1.1) (2026-01-07)
### Bug Fixes
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
## [2.1.0](https://github.com/the-luap/picpeak/compare/v2.0.0...v2.1.0) (2026-01-07)
### Features
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
### Bug Fixes
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
## [2.0.0](https://github.com/the-luap/picpeak/compare/v1.1.15...v2.0.0) (2026-01-03)
+41 -32
View File
@@ -450,6 +450,20 @@ ADMIN_EMAIL=your-email@yourdomain.com
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
### Routing Schema
PicPeak consists of two services that need to be routed correctly:
| Path | Service | Port | Description |
|------|---------|------|-------------|
| `/api/*` | Backend | 3001 | All API endpoints |
| `/photos/*` | Backend | 3001 | Protected photo files |
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
| `/uploads/*` | Backend | 3001 | Upload files |
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
### Option 1: Nginx
Install nginx and create `/etc/nginx/sites-available/picpeak`:
@@ -468,39 +482,32 @@ server {
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Frontend
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend (serves UI and /admin/*)
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API and protected resources
location /api {
# Backend: API endpoints
location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/(photos|thumbnails|uploads) {
# Backend: Protected media files
location ~ ^/(photos|thumbnails|uploads)/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend: Everything else (React SPA)
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
@@ -530,10 +537,16 @@ services:
backend:
labels:
- "traefik.enable=true"
# API endpoints
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
# Protected media files
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
```
### Option 3: Caddy
@@ -542,21 +555,12 @@ Create a `Caddyfile`:
```caddyfile
your-domain.com {
# Frontend
handle /* {
reverse_proxy localhost:3000
}
# Backend API and admin
# Backend: API endpoints
handle /api/* {
reverse_proxy localhost:3001
}
handle /admin/* {
reverse_proxy localhost:3001
}
# Protected resources
# Backend: Protected media files
handle /photos/* {
reverse_proxy localhost:3001
}
@@ -568,6 +572,11 @@ your-domain.com {
handle /uploads/* {
reverse_proxy localhost:3001
}
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
handle {
reverse_proxy localhost:3000
}
}
```
+1 -1
View File
@@ -248,7 +248,7 @@ These features are currently in beta testing and may have limited functionality
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
+6 -4
View File
@@ -12,7 +12,8 @@ LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
RUN npm install -g npm@latest
# Pin to npm 10.x which supports --omit=dev flag
RUN npm install -g npm@10
WORKDIR /app
@@ -34,7 +35,8 @@ WORKDIR /app
RUN apk upgrade --no-cache
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
RUN npm install -g npm@latest
# Pin to npm 10.x which supports --omit=dev flag
RUN npm install -g npm@10
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
@@ -46,8 +48,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .
# Make wait script executable
RUN chmod +x wait-for-db.sh
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
Binary file not shown.
@@ -0,0 +1,91 @@
/**
* Migration: Add Roles Table
* Creates the roles table for RBAC multi-administrator support.
*
* Default roles:
* - super_admin (priority 100): Full system access including user management
* - admin (priority 80): Full event and photo management
* - editor (priority 50): Can edit events and photos but not create or delete
* - viewer (priority 20): Read-only access to dashboard and events
*/
exports.up = async function(knex) {
console.log('Creating roles table...');
// Check if table already exists
const hasRolesTable = await knex.schema.hasTable('roles');
if (!hasRolesTable) {
await knex.schema.createTable('roles', (table) => {
table.increments('id').primary();
table.string('name', 50).unique().notNullable(); // 'super_admin', 'admin', 'editor', 'viewer'
table.string('display_name', 100).notNullable(); // 'Super Admin', 'Admin', etc.
table.text('description');
table.boolean('is_system').defaultTo(false); // System roles cannot be deleted
table.integer('priority').defaultTo(0); // Higher = more privileged (for hierarchy)
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Index for name lookups
table.index(['name']);
// Index for priority-based ordering
table.index(['priority']);
});
console.log('Roles table created');
}
// Insert default system roles
const existingRoles = await knex('roles').select('name');
const existingRoleNames = existingRoles.map(r => r.name);
const defaultRoles = [
{
name: 'super_admin',
display_name: 'Super Admin',
description: 'Full system access including user management',
is_system: true,
priority: 100
},
{
name: 'admin',
display_name: 'Admin',
description: 'Full event and photo management',
is_system: true,
priority: 80
},
{
name: 'editor',
display_name: 'Editor',
description: 'Can edit events and photos but not create or delete',
is_system: true,
priority: 50
},
{
name: 'viewer',
display_name: 'Viewer',
description: 'Read-only access to dashboard and events',
is_system: true,
priority: 20
}
];
const rolesToInsert = defaultRoles.filter(role => !existingRoleNames.includes(role.name));
if (rolesToInsert.length > 0) {
await knex('roles').insert(rolesToInsert);
console.log(`Inserted ${rolesToInsert.length} default roles`);
}
console.log('Roles table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing roles table...');
// Note: This will fail if there are foreign key references
// The role_permissions and admin_users tables must be rolled back first
await knex.schema.dropTableIfExists('roles');
console.log('Roles table removed');
};
@@ -0,0 +1,122 @@
/**
* Migration: Add Permissions Table
* Creates the permissions table for granular access control.
*
* Permission categories:
* - events: View, create, edit, delete, archive events
* - photos: View, upload, edit, delete, download photos
* - archives: View, restore, download, delete archives
* - analytics: View analytics and statistics
* - email: View, edit, send emails
* - branding: View and edit branding settings
* - cms: View and edit CMS pages
* - settings: View and edit application settings
* - backup: View, create, restore, delete backups
* - users: View, create, edit, delete admin users (Super Admin only)
* - activity: View and export activity logs
*/
exports.up = async function(knex) {
console.log('Creating permissions table...');
// Check if table already exists
const hasPermissionsTable = await knex.schema.hasTable('permissions');
if (!hasPermissionsTable) {
await knex.schema.createTable('permissions', (table) => {
table.increments('id').primary();
table.string('name', 100).unique().notNullable(); // 'events.create', 'users.manage', etc.
table.string('display_name', 150).notNullable();
table.string('category', 50).notNullable(); // 'events', 'photos', 'users', 'settings'
table.text('description');
table.timestamp('created_at').defaultTo(knex.fn.now());
// Indexes for efficient lookups
table.index(['name']);
table.index(['category']);
});
console.log('Permissions table created');
}
// Check for existing permissions
const existingPermissions = await knex('permissions').select('name');
const existingPermissionNames = existingPermissions.map(p => p.name);
// Define all permissions
const permissions = [
// Events
{ name: 'events.view', display_name: 'View Events', category: 'events', description: 'View event list and details' },
{ name: 'events.create', display_name: 'Create Events', category: 'events', description: 'Create new events' },
{ name: 'events.edit', display_name: 'Edit Events', category: 'events', description: 'Edit existing events' },
{ name: 'events.delete', display_name: 'Delete Events', category: 'events', description: 'Delete events' },
{ name: 'events.archive', display_name: 'Archive Events', category: 'events', description: 'Archive and restore events' },
// Photos
{ name: 'photos.view', display_name: 'View Photos', category: 'photos', description: 'View photos in events' },
{ name: 'photos.upload', display_name: 'Upload Photos', category: 'photos', description: 'Upload photos to events' },
{ name: 'photos.edit', display_name: 'Edit Photos', category: 'photos', description: 'Edit photo metadata and categories' },
{ name: 'photos.delete', display_name: 'Delete Photos', category: 'photos', description: 'Delete photos from events' },
{ name: 'photos.download', display_name: 'Download Photos', category: 'photos', description: 'Download photos and bulk export' },
// Archives
{ name: 'archives.view', display_name: 'View Archives', category: 'archives', description: 'View archived events' },
{ name: 'archives.restore', display_name: 'Restore Archives', category: 'archives', description: 'Restore archived events' },
{ name: 'archives.download', display_name: 'Download Archives', category: 'archives', description: 'Download archive files' },
{ name: 'archives.delete', display_name: 'Delete Archives', category: 'archives', description: 'Permanently delete archives' },
// Analytics
{ name: 'analytics.view', display_name: 'View Analytics', category: 'analytics', description: 'View analytics and statistics' },
// Email
{ name: 'email.view', display_name: 'View Email Settings', category: 'email', description: 'View email configuration' },
{ name: 'email.edit', display_name: 'Edit Email Settings', category: 'email', description: 'Configure email settings and templates' },
{ name: 'email.send', display_name: 'Send Emails', category: 'email', description: 'Send and resend gallery emails' },
// Branding & CMS
{ name: 'branding.view', display_name: 'View Branding', category: 'branding', description: 'View branding settings' },
{ name: 'branding.edit', display_name: 'Edit Branding', category: 'branding', description: 'Edit branding and theme settings' },
{ name: 'cms.view', display_name: 'View CMS Pages', category: 'cms', description: 'View CMS content pages' },
{ name: 'cms.edit', display_name: 'Edit CMS Pages', category: 'cms', description: 'Edit CMS content pages' },
// Settings
{ name: 'settings.view', display_name: 'View Settings', category: 'settings', description: 'View application settings' },
{ name: 'settings.edit', display_name: 'Edit Settings', category: 'settings', description: 'Modify application settings' },
// Backup
{ name: 'backup.view', display_name: 'View Backups', category: 'backup', description: 'View backup status and history' },
{ name: 'backup.create', display_name: 'Create Backups', category: 'backup', description: 'Create new backups' },
{ name: 'backup.restore', display_name: 'Restore Backups', category: 'backup', description: 'Restore from backups' },
{ name: 'backup.delete', display_name: 'Delete Backups', category: 'backup', description: 'Delete backup files' },
// User Management (Super Admin only)
{ name: 'users.view', display_name: 'View Users', category: 'users', description: 'View admin user list' },
{ name: 'users.create', display_name: 'Create Users', category: 'users', description: 'Invite new admin users' },
{ name: 'users.edit', display_name: 'Edit Users', category: 'users', description: 'Edit admin user details and roles' },
{ name: 'users.delete', display_name: 'Delete Users', category: 'users', description: 'Deactivate or delete admin users' },
// Activity Logs
{ name: 'activity.view', display_name: 'View Activity Logs', category: 'activity', description: 'View system activity logs' },
{ name: 'activity.export', display_name: 'Export Activity Logs', category: 'activity', description: 'Export activity logs' }
];
// Filter out already existing permissions
const permissionsToInsert = permissions.filter(p => !existingPermissionNames.includes(p.name));
if (permissionsToInsert.length > 0) {
await knex('permissions').insert(permissionsToInsert);
console.log(`Inserted ${permissionsToInsert.length} permissions`);
}
console.log('Permissions table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing permissions table...');
// Note: This will fail if there are foreign key references
// The role_permissions table must be rolled back first
await knex.schema.dropTableIfExists('permissions');
console.log('Permissions table removed');
};
@@ -0,0 +1,134 @@
/**
* Migration: Add Role Permissions Junction Table
* Creates the junction table mapping permissions to roles.
*
* Role permission mappings:
* - super_admin: All permissions
* - admin: Events, Photos, Archives, Analytics, Email, Branding, CMS, Settings (view), Backup (view/create), Activity (view)
* - editor: View/Create/Edit own events and photos, Analytics (view), Activity (view)
* - viewer: View-only access to events, photos, archives, analytics, branding, cms
*/
exports.up = async function(knex) {
console.log('Creating role_permissions junction table...');
// Check if table already exists
const hasRolePermissionsTable = await knex.schema.hasTable('role_permissions');
if (!hasRolePermissionsTable) {
await knex.schema.createTable('role_permissions', (table) => {
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE');
table.integer('permission_id').unsigned().references('id').inTable('permissions').onDelete('CASCADE');
table.primary(['role_id', 'permission_id']);
// Indexes for efficient lookups
table.index(['role_id']);
table.index(['permission_id']);
});
console.log('Role permissions junction table created');
}
// Get role and permission IDs
const roles = await knex('roles').select('id', 'name');
const permissions = await knex('permissions').select('id', 'name');
if (roles.length === 0 || permissions.length === 0) {
console.log('No roles or permissions found, skipping permission mappings');
return;
}
const roleMap = Object.fromEntries(roles.map(r => [r.name, r.id]));
const permMap = Object.fromEntries(permissions.map(p => [p.name, p.id]));
// Define role-permission mappings
const rolePermissions = {
super_admin: permissions.map(p => p.name), // All permissions
admin: [
// Events - full access
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
// Photos - full access
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
// Archives - full access
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
// Analytics - view only
'analytics.view',
// Email - full access
'email.view', 'email.edit', 'email.send',
// Branding - full access
'branding.view', 'branding.edit',
// CMS - full access
'cms.view', 'cms.edit',
// Settings - view only
'settings.view',
// Backup - view and create only
'backup.view', 'backup.create',
// Activity - view only
'activity.view'
],
editor: [
// Events - view, create, and edit (can only see their own events)
'events.view', 'events.create', 'events.edit',
// Photos - view, upload, edit (no delete)
'photos.view', 'photos.upload', 'photos.edit',
// Analytics - view only
'analytics.view',
// Activity - view only
'activity.view'
],
viewer: [
// Events - view only
'events.view',
// Photos - view only
'photos.view',
// Archives - view only
'archives.view',
// Analytics - view only
'analytics.view',
// Branding - view only
'branding.view',
// CMS - view only
'cms.view'
]
};
// Check for existing mappings to avoid duplicates
const existingMappings = await knex('role_permissions').select('role_id', 'permission_id');
const existingSet = new Set(existingMappings.map(m => `${m.role_id}-${m.permission_id}`));
// Build insert list
const inserts = [];
for (const [roleName, perms] of Object.entries(rolePermissions)) {
for (const permName of perms) {
if (roleMap[roleName] && permMap[permName]) {
const key = `${roleMap[roleName]}-${permMap[permName]}`;
if (!existingSet.has(key)) {
inserts.push({
role_id: roleMap[roleName],
permission_id: permMap[permName]
});
}
}
}
}
if (inserts.length > 0) {
// Insert in batches to avoid hitting database limits
const batchSize = 50;
for (let i = 0; i < inserts.length; i += batchSize) {
const batch = inserts.slice(i, i + batchSize);
await knex('role_permissions').insert(batch);
}
console.log(`Inserted ${inserts.length} role-permission mappings`);
}
console.log('Role permissions junction table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing role_permissions junction table...');
await knex.schema.dropTableIfExists('role_permissions');
console.log('Role permissions junction table removed');
};
@@ -0,0 +1,115 @@
/**
* Migration: Add Role to Admin Users
* Adds RBAC-related columns to the admin_users table:
* - role_id: Foreign key to roles table
* - created_by: Foreign key to admin_users (who invited this user)
* - invite_token: Token for invitation acceptance (64 chars = 256 bits)
* - invite_expires_at: When the invitation token expires
* - invite_accepted_at: When the user accepted the invitation
*
* Also migrates existing admin users to super_admin role.
*/
exports.up = async function(knex) {
console.log('Adding role columns to admin_users table...');
// Check if columns already exist
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
// Add new columns if they don't exist
if (!hasRoleId || !hasCreatedBy || !hasInviteToken || !hasInviteExpiresAt || !hasInviteAcceptedAt) {
await knex.schema.alterTable('admin_users', (table) => {
if (!hasRoleId) {
// Note: We add as nullable first, then set values, then alter to not null
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('SET NULL');
}
if (!hasCreatedBy) {
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
}
if (!hasInviteToken) {
// 64 characters = 32 bytes hex = 256 bits of entropy (cryptographically secure)
table.string('invite_token', 64);
}
if (!hasInviteExpiresAt) {
table.timestamp('invite_expires_at');
}
if (!hasInviteAcceptedAt) {
table.timestamp('invite_accepted_at');
}
});
console.log('Role columns added to admin_users table');
}
// Add index on invite_token for fast lookup
const hasInviteTokenIndex = await knex.schema.hasColumn('admin_users', 'invite_token');
if (hasInviteTokenIndex) {
// Create index if it doesn't exist (safe for both PostgreSQL and SQLite)
try {
await knex.schema.alterTable('admin_users', (table) => {
table.index(['invite_token']);
});
} catch (e) {
// Index may already exist
if (!e.message.includes('already exists')) {
console.log('Note: invite_token index may already exist');
}
}
}
// Get super_admin role ID
const superAdminRole = await knex('roles').where('name', 'super_admin').first();
if (superAdminRole) {
// Migrate existing admin users without a role to super_admin
const usersWithoutRole = await knex('admin_users')
.whereNull('role_id')
.select('id');
if (usersWithoutRole.length > 0) {
await knex('admin_users')
.whereNull('role_id')
.update({ role_id: superAdminRole.id });
console.log(`Migrated ${usersWithoutRole.length} existing admin user(s) to super_admin role`);
}
} else {
console.log('Warning: super_admin role not found. Run migration 054 first.');
}
console.log('Admin users role migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing role columns from admin_users table...');
const hasRoleId = await knex.schema.hasColumn('admin_users', 'role_id');
const hasCreatedBy = await knex.schema.hasColumn('admin_users', 'created_by');
const hasInviteToken = await knex.schema.hasColumn('admin_users', 'invite_token');
const hasInviteExpiresAt = await knex.schema.hasColumn('admin_users', 'invite_expires_at');
const hasInviteAcceptedAt = await knex.schema.hasColumn('admin_users', 'invite_accepted_at');
await knex.schema.alterTable('admin_users', (table) => {
if (hasInviteAcceptedAt) {
table.dropColumn('invite_accepted_at');
}
if (hasInviteExpiresAt) {
table.dropColumn('invite_expires_at');
}
if (hasInviteToken) {
table.dropColumn('invite_token');
}
if (hasCreatedBy) {
table.dropColumn('created_by');
}
if (hasRoleId) {
table.dropColumn('role_id');
}
});
console.log('Role columns removed from admin_users table');
};
@@ -0,0 +1,68 @@
/**
* Migration: Add Admin Invitations Table
* Creates the admin_invitations table for managing pending admin user invitations.
*
* Security features:
* - Token is 64 characters (32 bytes hex = 256 bits of entropy)
* - Tokens are unique and indexed for fast lookup
* - Invitations have expiration timestamps
* - Tracks who invited whom and when accepted
* - Foreign key constraints with appropriate CASCADE behavior
*/
exports.up = async function(knex) {
console.log('Creating admin_invitations table...');
// Check if table already exists
const hasAdminInvitationsTable = await knex.schema.hasTable('admin_invitations');
if (!hasAdminInvitationsTable) {
await knex.schema.createTable('admin_invitations', (table) => {
table.increments('id').primary();
// Email of the invited user
table.string('email', 255).notNullable();
// Invitation token - 64 characters = 32 bytes hex = 256 bits of entropy
// Cryptographically secure for one-time use tokens
table.string('token', 64).unique().notNullable();
// Role to assign when invitation is accepted
table.integer('role_id').unsigned().references('id').inTable('roles').onDelete('CASCADE').notNullable();
// Who created this invitation
table.integer('invited_by').unsigned().references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
// When the invitation expires (typically 7 days from creation)
table.timestamp('expires_at').notNullable();
// When the invitation was accepted (null if pending)
table.timestamp('accepted_at');
// The admin_user ID created when invitation was accepted (for audit trail)
table.integer('accepted_user_id').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
// When the invitation was created
table.timestamp('created_at').defaultTo(knex.fn.now());
// Indexes for efficient lookups
table.index(['token']); // Fast token validation
table.index(['email']); // Check for existing invitations by email
table.index(['expires_at']); // Cleanup expired invitations
table.index(['invited_by']); // List invitations by inviter
table.index(['accepted_at']); // Filter pending vs accepted
});
console.log('Admin invitations table created');
}
console.log('Admin invitations table migration completed successfully');
};
exports.down = async function(knex) {
console.log('Removing admin_invitations table...');
await knex.schema.dropTableIfExists('admin_invitations');
console.log('Admin invitations table removed');
};
@@ -0,0 +1,239 @@
/**
* Migration to add email templates for admin invitation and password reset
* These templates support the RBAC (Role-Based Access Control) feature
*/
exports.up = async function(knex) {
// Check which templates already exist
const existingTemplates = await knex('email_templates')
.select('template_key')
.whereIn('template_key', ['admin_invitation', 'admin_password_reset']);
const existingKeys = existingTemplates.map(t => t.template_key);
// Admin Invitation Email Template
if (!existingKeys.includes('admin_invitation')) {
await knex('email_templates').insert({
template_key: 'admin_invitation',
subject_en: 'You have been invited to join PicPeak as {{role_name}}',
subject_de: 'Sie wurden eingeladen, PicPeak als {{role_name}} beizutreten',
body_html_en: `
<h2>Welcome to PicPeak!</h2>
<p>You have been invited to join the PicPeak photo sharing platform as a <strong>{{role_name}}</strong>.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Your Role:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">This role grants you access to manage and administer the photo sharing platform.</p>
</div>
<p>To accept this invitation and set up your account, click the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Accept Invitation</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Important:</strong> This invitation expires on <strong>{{expires_at}}</strong>. Please accept the invitation before this date.</p>
</div>
<p>If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
If the button above does not work, copy and paste this link into your browser:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Best regards,<br>
The PicPeak Team</p>`,
body_text_en: `Welcome to PicPeak!
You have been invited to join the PicPeak photo sharing platform as a {{role_name}}.
Your Role: {{role_name}}
This role grants you access to manage and administer the photo sharing platform.
To accept this invitation and set up your account, visit the following link:
{{invite_link}}
IMPORTANT: This invitation expires on {{expires_at}}. Please accept the invitation before this date.
If you did not expect this invitation or believe it was sent in error, you can safely ignore this email.
Best regards,
The PicPeak Team`,
body_html_de: `
<h2>Willkommen bei PicPeak!</h2>
<p>Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als <strong>{{role_name}}</strong> beizutreten.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Ihre Rolle:</strong> {{role_name}}</p>
<p style="margin: 10px 0 0 0;">Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.</p>
</div>
<p>Um diese Einladung anzunehmen und Ihr Konto einzurichten, klicken Sie auf die Schaltflache unten:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{invite_link}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Einladung annehmen</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Wichtig:</strong> Diese Einladung lauft am <strong>{{expires_at}}</strong> ab. Bitte nehmen Sie die Einladung vor diesem Datum an.</p>
</div>
<p>Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.</p>
<p style="color: #666; font-size: 13px; margin-top: 30px;">
Wenn die Schaltflache oben nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:<br>
<a href="{{invite_link}}" style="color: #5C8762; word-break: break-all;">{{invite_link}}</a>
</p>
<p>Mit freundlichen Grussen,<br>
Ihr PicPeak-Team</p>`,
body_text_de: `Willkommen bei PicPeak!
Sie wurden eingeladen, der PicPeak Foto-Sharing-Plattform als {{role_name}} beizutreten.
Ihre Rolle: {{role_name}}
Diese Rolle gewahrt Ihnen Zugang zur Verwaltung und Administration der Foto-Sharing-Plattform.
Um diese Einladung anzunehmen und Ihr Konto einzurichten, besuchen Sie den folgenden Link:
{{invite_link}}
WICHTIG: Diese Einladung lauft am {{expires_at}} ab. Bitte nehmen Sie die Einladung vor diesem Datum an.
Wenn Sie diese Einladung nicht erwartet haben oder glauben, dass sie irrtumlicherweise gesendet wurde, konnen Sie diese E-Mail ignorieren.
Mit freundlichen Grussen,
Ihr PicPeak-Team`,
variables: JSON.stringify(['invite_link', 'role_name', 'expires_at'])
});
}
// Admin Password Reset Email Template
if (!existingKeys.includes('admin_password_reset')) {
await knex('email_templates').insert({
template_key: 'admin_password_reset',
subject_en: 'Your PicPeak administrator password has been reset',
subject_de: 'Ihr PicPeak-Administratorpasswort wurde zuruckgesetzt',
body_html_en: `
<h2>Password Reset Notification</h2>
<p>Hello <strong>{{username}}</strong>,</p>
<p>Your administrator password for PicPeak has been reset by a system administrator.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Your New Login Credentials:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Username:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Temporary Password:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Security Notice</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>This is a temporary password. Please change it immediately after logging in.</li>
<li>Never share your password with anyone.</li>
<li>If you did not request this password reset, please contact your system administrator immediately.</li>
</ul>
</div>
<p>To log in to the admin panel, click the button below:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Log In Now</a>
</div>
<p style="color: #666; font-size: 13px;">After logging in, navigate to your profile settings to change your password to something secure that only you know.</p>
<p>Best regards,<br>
The PicPeak Team</p>`,
body_text_en: `Password Reset Notification
Hello {{username}},
Your administrator password for PicPeak has been reset by a system administrator.
Your New Login Credentials:
- Username: {{username}}
- Temporary Password: {{new_password}}
SECURITY NOTICE:
- This is a temporary password. Please change it immediately after logging in.
- Never share your password with anyone.
- If you did not request this password reset, please contact your system administrator immediately.
To log in to the admin panel, visit: {{admin_login_url}}
After logging in, navigate to your profile settings to change your password to something secure that only you know.
Best regards,
The PicPeak Team`,
body_html_de: `
<h2>Benachrichtigung uber Passwortzurucksetzung</h2>
<p>Hallo <strong>{{username}}</strong>,</p>
<p>Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.</p>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Ihre neuen Anmeldedaten:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Benutzername:</strong> {{username}}</li>
<li style="margin-bottom: 10px;"><strong>Vorlaufiges Passwort:</strong> <code style="background-color: #e9ecef; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px;">{{new_password}}</code></li>
</ul>
</div>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">Sicherheitshinweis</p>
<ul style="margin: 10px 0 0 0; padding-left: 20px;">
<li>Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.</li>
<li>Teilen Sie Ihr Passwort niemals mit anderen.</li>
<li>Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.</li>
</ul>
</div>
<p>Um sich im Admin-Panel anzumelden, klicken Sie auf die Schaltflache unten:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{admin_login_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Jetzt anmelden</a>
</div>
<p style="color: #666; font-size: 13px;">Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.</p>
<p>Mit freundlichen Grussen,<br>
Ihr PicPeak-Team</p>`,
body_text_de: `Benachrichtigung uber Passwortzurucksetzung
Hallo {{username}},
Ihr Administratorpasswort fur PicPeak wurde von einem Systemadministrator zuruckgesetzt.
Ihre neuen Anmeldedaten:
- Benutzername: {{username}}
- Vorlaufiges Passwort: {{new_password}}
SICHERHEITSHINWEIS:
- Dies ist ein vorlaufiges Passwort. Bitte andern Sie es sofort nach der Anmeldung.
- Teilen Sie Ihr Passwort niemals mit anderen.
- Wenn Sie diese Passwortzurucksetzung nicht angefordert haben, wenden Sie sich bitte umgehend an Ihren Systemadministrator.
Um sich im Admin-Panel anzumelden, besuchen Sie: {{admin_login_url}}
Nach der Anmeldung navigieren Sie zu Ihren Profileinstellungen, um Ihr Passwort in ein sicheres Passwort zu andern, das nur Sie kennen.
Mit freundlichen Grussen,
Ihr PicPeak-Team`,
variables: JSON.stringify(['username', 'new_password', 'admin_login_url'])
});
}
};
exports.down = async function(knex) {
// Remove the admin email templates
await knex('email_templates')
.whereIn('template_key', ['admin_invitation', 'admin_password_reset'])
.delete();
};
@@ -0,0 +1,23 @@
/**
* Migration: Add created_by column to events table
* This allows filtering events by owner for role-based access control
*/
exports.up = async function(knex) {
// Add created_by column to events table
await knex.schema.alterTable('events', (table) => {
table.integer('created_by').unsigned().references('id').inTable('admin_users').onDelete('SET NULL');
});
// Set existing events to be owned by the first admin (super_admin)
const superAdmin = await knex('admin_users').where('role_id', 1).first();
if (superAdmin) {
await knex('events').update({ created_by: superAdmin.id });
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('created_by');
});
};
+120 -134
View File
@@ -30,6 +30,7 @@
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
@@ -258,33 +259,33 @@
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.962.0.tgz",
"integrity": "sha512-I2/1McBZCcM3PfM4ck8D6gnZR3K7+yl1fGkwTq/3ThEn9tdLjNwcdgTbPfxfX6LoecLrH9Ekoo+D9nmQ0T261w==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.964.0.tgz",
"integrity": "sha512-mDK+3qpfHnEPXeF6D8nQkJOkOvchllQosgfxv0FK9PNBuU9WVkP8yj7y3YwH6JYTgy1ejz1Ju/YfoUbbE6m7zw==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/credential-provider-node": "3.962.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/credential-provider-node": "3.964.0",
"@aws-sdk/middleware-bucket-endpoint": "3.957.0",
"@aws-sdk/middleware-expect-continue": "3.957.0",
"@aws-sdk/middleware-flexible-checksums": "3.957.0",
"@aws-sdk/middleware-flexible-checksums": "3.964.0",
"@aws-sdk/middleware-host-header": "3.957.0",
"@aws-sdk/middleware-location-constraint": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "3.957.0",
"@aws-sdk/middleware-sdk-s3": "3.957.0",
"@aws-sdk/middleware-sdk-s3": "3.964.0",
"@aws-sdk/middleware-ssec": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/region-config-resolver": "3.957.0",
"@aws-sdk/signature-v4-multi-region": "3.957.0",
"@aws-sdk/signature-v4-multi-region": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.964.0",
"@smithy/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0",
"@smithy/eventstream-serde-browser": "^4.2.7",
@@ -325,23 +326,23 @@
}
},
"node_modules/@aws-sdk/client-sso": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz",
"integrity": "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.964.0.tgz",
"integrity": "sha512-IenVyY8Io2CwBgmS22xk/H5LibmSbvLnPA9oFqLORO6Ji1Ks8z/ow+ud/ZurVjFekz3LD/uxVFX3ZKGo6N7Byw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/middleware-host-header": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/region-config-resolver": "3.957.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.964.0",
"@smithy/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0",
"@smithy/fetch-http-handler": "^5.3.8",
@@ -374,9 +375,9 @@
}
},
"node_modules/@aws-sdk/core": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.957.0.tgz",
"integrity": "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.964.0.tgz",
"integrity": "sha512-1gIfbt0KRxI8am1UYFcIxQ5QKb22JyN3k52sxyrKXJYC8Knn/rTUAZbYti45CfETe5PLadInGvWqClwGRlZKNg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "3.957.0",
@@ -411,12 +412,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.957.0.tgz",
"integrity": "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.964.0.tgz",
"integrity": "sha512-jWNSXOOBMYuxzI2rXi8x91YL07dhomyGzzh0CdaLej0LRmknmDrZcZNkVpa7Fredy1PFcmOlokwCS5PmZMN8ZQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/types": "^4.11.0",
@@ -427,12 +428,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.957.0.tgz",
"integrity": "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.964.0.tgz",
"integrity": "sha512-up7dl6vcaoXuYSwGXDvx8RnF8Lwj3jGChhyUR7krZOXLarIfUUN3ILOZnVNK5s/HnVNkEILlkdPvjhr9LVC1/Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/fetch-http-handler": "^5.3.8",
"@smithy/node-http-handler": "^4.4.7",
@@ -448,19 +449,19 @@
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.962.0.tgz",
"integrity": "sha512-h0kVnXLW2d3nxbcrR/Pfg3W/+YoCguasWz7/3nYzVqmdKarGrpJzaFdoZtLgvDSZ8VgWUC4lWOTcsDMV0UNqUQ==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.964.0.tgz",
"integrity": "sha512-t4FN9qTWU4nXDU6EQ6jopvyhXw0dbQ3n+3g6x5hmc1ECFAqA+xmFd1i5LljdZCi79cUXHduQWwvW8RJHMf0qJw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/credential-provider-env": "3.957.0",
"@aws-sdk/credential-provider-http": "3.957.0",
"@aws-sdk/credential-provider-login": "3.962.0",
"@aws-sdk/credential-provider-process": "3.957.0",
"@aws-sdk/credential-provider-sso": "3.958.0",
"@aws-sdk/credential-provider-web-identity": "3.958.0",
"@aws-sdk/nested-clients": "3.958.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/credential-provider-env": "3.964.0",
"@aws-sdk/credential-provider-http": "3.964.0",
"@aws-sdk/credential-provider-login": "3.964.0",
"@aws-sdk/credential-provider-process": "3.964.0",
"@aws-sdk/credential-provider-sso": "3.964.0",
"@aws-sdk/credential-provider-web-identity": "3.964.0",
"@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/credential-provider-imds": "^4.2.7",
"@smithy/property-provider": "^4.2.7",
@@ -473,13 +474,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.962.0.tgz",
"integrity": "sha512-kHYH6Av2UifG3mPkpPUNRh/PuX6adaAcpmsclJdHdxlixMCRdh8GNeEihq480DC0GmfqdpoSf1w2CLmLLPIS6w==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.964.0.tgz",
"integrity": "sha512-c64dmTizMkJXDRzN3NYPTmUpKxegr5lmLOYPeQ60Zcbft6HFwPme8Gwy8pNxO4gG1fw6Ja2Vu6fZuSTn8aDFOQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/nested-clients": "3.958.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/protocol-http": "^5.3.7",
@@ -492,17 +493,17 @@
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.962.0.tgz",
"integrity": "sha512-CS78NsWRxLa+nWqeWBEYMZTLacMFIXs1C5WJuM9kD05LLiWL32ksljoPsvNN24Bc7rCSQIIMx/U3KGvkDVZMVg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.964.0.tgz",
"integrity": "sha512-FHxDXPOj888/qc/X8s0x4aUBdp4Y3k9VePRehUJBWRhhTsAyuIJis5V0iQeY1qvtqHXYa2qd1EZHGJ3bTjHxSw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "3.957.0",
"@aws-sdk/credential-provider-http": "3.957.0",
"@aws-sdk/credential-provider-ini": "3.962.0",
"@aws-sdk/credential-provider-process": "3.957.0",
"@aws-sdk/credential-provider-sso": "3.958.0",
"@aws-sdk/credential-provider-web-identity": "3.958.0",
"@aws-sdk/credential-provider-env": "3.964.0",
"@aws-sdk/credential-provider-http": "3.964.0",
"@aws-sdk/credential-provider-ini": "3.964.0",
"@aws-sdk/credential-provider-process": "3.964.0",
"@aws-sdk/credential-provider-sso": "3.964.0",
"@aws-sdk/credential-provider-web-identity": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/credential-provider-imds": "^4.2.7",
"@smithy/property-provider": "^4.2.7",
@@ -515,12 +516,12 @@
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.957.0.tgz",
"integrity": "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.964.0.tgz",
"integrity": "sha512-HaTLKqj3jeZY88E/iBjsNJsXgmRTTT7TghqeRiF8FKb/7UY1xEvasBO0c1xqfOye8dsyt35nTfTTyIsd/CBfww==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2",
@@ -532,14 +533,14 @@
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.958.0.tgz",
"integrity": "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.964.0.tgz",
"integrity": "sha512-oR78TjSpjVf1IpPWQnGHEGqlnQs+K4f5nCxLK2P6JDPprXay6oknsoSiU4x2urav6VCyMPMC9KTCGjBoFKUIxQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/client-sso": "3.958.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/token-providers": "3.958.0",
"@aws-sdk/client-sso": "3.964.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/token-providers": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2",
@@ -551,13 +552,13 @@
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.958.0.tgz",
"integrity": "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.964.0.tgz",
"integrity": "sha512-07JQDmbjZjOt3nL/j1wTcvQqjmPkynQYftUV/ooZ+qTbmJXFbCBdal1VCElyeiu0AgBq9dfhw0rBBcbND1ZMlA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/nested-clients": "3.958.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2",
@@ -569,9 +570,9 @@
}
},
"node_modules/@aws-sdk/lib-storage": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.962.0.tgz",
"integrity": "sha512-Ai5gWRQkzsUMQ6NPoZZoiLXoQ6/yPRcR4oracIVjyWcu48TfBpsRgbqY/5zNOM55ag1wPX9TtJJGOhK3TNk45g==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.964.0.tgz",
"integrity": "sha512-ro6B04Q5TjPgIKdSWGJ+tj2ordVF1IfZJERwGpYkrwhboNEoXBXuzpfnh2LYBPvMmFJQ+8UXSFw1jkLLgxM+ig==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/abort-controller": "^4.2.7",
@@ -586,7 +587,7 @@
"node": ">=18.0.0"
},
"peerDependencies": {
"@aws-sdk/client-s3": "^3.962.0"
"@aws-sdk/client-s3": "^3.964.0"
}
},
"node_modules/@aws-sdk/middleware-bucket-endpoint": {
@@ -623,15 +624,15 @@
}
},
"node_modules/@aws-sdk/middleware-flexible-checksums": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.957.0.tgz",
"integrity": "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.964.0.tgz",
"integrity": "sha512-IA2kSKkwC/HHFF75nTR7s/nWt5CboB6vMgpLpvx40Cc01cMp+06Jr7U2/+DPPc8fkCagTytchY4gX9Hzn5ej8g==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/crc32": "5.2.0",
"@aws-crypto/crc32c": "5.2.0",
"@aws-crypto/util": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/crc64-nvme": "3.957.0",
"@aws-sdk/types": "3.957.0",
"@smithy/is-array-buffer": "^4.2.0",
@@ -707,12 +708,12 @@
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.957.0.tgz",
"integrity": "sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.964.0.tgz",
"integrity": "sha512-SeFcLo3tUdI3amzoIiArd9O0i7vAB0n5fgbQHBu137s3SbSLO5tPspE25rrUITwlc5HTbHMK6UzBq+3hITmImA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-arn-parser": "3.957.0",
"@smithy/core": "^3.20.0",
@@ -746,12 +747,12 @@
}
},
"node_modules/@aws-sdk/middleware-user-agent": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.957.0.tgz",
"integrity": "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.964.0.tgz",
"integrity": "sha512-/QyBl8WLNtqw3ucyAggumQXVCi8GRxaDGE1ElyYMmacfiwHl37S9y8JVW/QLL1lIEXGcsrhMUKV3pyFJFALA7w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@smithy/core": "^3.20.0",
@@ -764,23 +765,23 @@
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.958.0.tgz",
"integrity": "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.964.0.tgz",
"integrity": "sha512-ql+ftRwjyZkZeG3qbrRJFVmNR0id83WEUqhFVjvrQMWspNApBhz0Ar4YVSn7Uv0QaKkaR7ALPtmdMzFr3/E4bQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.957.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/middleware-host-header": "3.957.0",
"@aws-sdk/middleware-logger": "3.957.0",
"@aws-sdk/middleware-recursion-detection": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/region-config-resolver": "3.957.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-endpoints": "3.957.0",
"@aws-sdk/util-user-agent-browser": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.957.0",
"@aws-sdk/util-user-agent-node": "3.964.0",
"@smithy/config-resolver": "^4.4.5",
"@smithy/core": "^3.20.0",
"@smithy/fetch-http-handler": "^5.3.8",
@@ -829,12 +830,12 @@
}
},
"node_modules/@aws-sdk/s3-request-presigner": {
"version": "3.962.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.962.0.tgz",
"integrity": "sha512-tyxsGfLY4NSohLrJsFGXbE3j8jguWK+hdGaUQSD1gJPvmC0B82qOyJ7WBIJLWgTabU3fiF/I9EGXjzR2rKr8jQ==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.964.0.tgz",
"integrity": "sha512-gKKdIZGYV8Ohm3X8j3y6Xr2ua1oD/Wsa3N7hYro3HqcnuGvl1h+mdw0IqUU+5yEzcoM5ItLJnH+6Q8Xz+Wv9gw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/signature-v4-multi-region": "3.957.0",
"@aws-sdk/signature-v4-multi-region": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@aws-sdk/util-format-url": "3.957.0",
"@smithy/middleware-endpoint": "^4.4.1",
@@ -848,12 +849,12 @@
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.957.0.tgz",
"integrity": "sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.964.0.tgz",
"integrity": "sha512-ASQmO9EB2ukSTGpO7B2ZceSbNVivCLqWh89o/JJtcIdGpOu8p9XHpeK3hiUz2OQo2Igw03/n8s+DNvP+N9krpw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-sdk-s3": "3.957.0",
"@aws-sdk/middleware-sdk-s3": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/protocol-http": "^5.3.7",
"@smithy/signature-v4": "^5.3.7",
@@ -865,13 +866,13 @@
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.958.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.958.0.tgz",
"integrity": "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.964.0.tgz",
"integrity": "sha512-UqouLQbYepZnMFJGB/DVpA5GhF9uT98vNWSMz9PVbhgEPUKa73FECRT6YFZvZOh8kA+0JiENrnmS6d93I70ykQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "3.957.0",
"@aws-sdk/nested-clients": "3.958.0",
"@aws-sdk/core": "3.964.0",
"@aws-sdk/nested-clients": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/property-provider": "^4.2.7",
"@smithy/shared-ini-file-loader": "^4.4.2",
@@ -963,12 +964,12 @@
}
},
"node_modules/@aws-sdk/util-user-agent-node": {
"version": "3.957.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.957.0.tgz",
"integrity": "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==",
"version": "3.964.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.964.0.tgz",
"integrity": "sha512-jgob8Z/bZIh1dwEgLqE12q+aCf0ieLy7anT8bWpqMijMJqsnrPBToa7smSykfom9YHrdOgrQhXswMpE75dzLRw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/middleware-user-agent": "3.957.0",
"@aws-sdk/middleware-user-agent": "3.964.0",
"@aws-sdk/types": "3.957.0",
"@smithy/node-config-provider": "^4.3.7",
"@smithy/types": "^4.11.0",
@@ -5399,30 +5400,6 @@
"node": ">= 0.8"
}
},
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -6883,12 +6860,12 @@
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
"node": ">= 10"
}
},
"node_modules/is-arrayish": {
@@ -9661,6 +9638,15 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-addr/node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.1.15",
"version": "2.2.2",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -34,6 +34,7 @@
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
+2
View File
@@ -436,6 +436,8 @@ app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/invite', require('./src/routes/acceptInvite'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
+37 -10
View File
@@ -57,32 +57,59 @@ async function adminAuth(req, res, next) {
});
}
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
// Check if admin still exists and is active, including role info
// Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario)
let admin;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.password_changed_at',
'roles.id as role_id',
'roles.name as role_name'
)
.first();
} catch (joinError) {
// Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'password_changed_at')
.first();
if (admin) {
admin.role_id = null;
admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade
}
}
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if password was changed after token was issued
if (admin.password_changed_at) {
const passwordChangedTime = new Date(admin.password_changed_at).getTime() / 1000;
if (decoded.iat < passwordChangedTime) {
logger.warn('Token used after password change', { userId: decoded.id });
return res.status(401).json({
return res.status(401).json({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
}
// Add user info to request
// Add user info to request (enhanced with role)
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email
email: admin.email,
roleId: admin.role_id,
roleName: admin.role_name
};
req.token = token; // Store token for potential revocation
+242
View File
@@ -0,0 +1,242 @@
/**
* Permission Checking Middleware for RBAC
* Provides role-based access control with caching for performance
*/
const { db } = require('../database/db');
const { ForbiddenError } = require('../utils/errors');
const logger = require('../utils/logger');
// Cache for role permissions (refreshed periodically)
let permissionCache = new Map();
let cacheLastUpdated = 0;
const CACHE_TTL = 60000; // 1 minute
/**
* Refresh permission cache from database
* Handles upgrade scenario where RBAC tables may not exist yet
*/
async function refreshPermissionCache() {
const now = Date.now();
if (now - cacheLastUpdated < CACHE_TTL && permissionCache.size > 0) {
return;
}
try {
const rolePermissions = await db('role_permissions')
.join('roles', 'roles.id', 'role_permissions.role_id')
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
.select('roles.name as role_name', 'permissions.name as permission_name');
const newCache = new Map();
for (const rp of rolePermissions) {
if (!newCache.has(rp.role_name)) {
newCache.set(rp.role_name, new Set());
}
newCache.get(rp.role_name).add(rp.permission_name);
}
permissionCache = newCache;
cacheLastUpdated = now;
} catch (error) {
// Handle case where RBAC tables don't exist yet (upgrade scenario)
// Grant super_admin all permissions by default during upgrade window
if (error.message.includes('no such table') || error.message.includes('does not exist') || error.message.includes('relation')) {
logger.warn('RBAC tables not available yet - granting full access to authenticated users during upgrade');
const allPermissions = new Set([
'events.view', 'events.create', 'events.edit', 'events.delete', 'events.archive',
'photos.view', 'photos.upload', 'photos.edit', 'photos.delete', 'photos.download',
'archives.view', 'archives.restore', 'archives.download', 'archives.delete',
'analytics.view', 'email.view', 'email.edit', 'email.send',
'branding.view', 'branding.edit', 'cms.view', 'cms.edit',
'settings.view', 'settings.edit', 'backup.view', 'backup.create', 'backup.restore', 'backup.delete',
'users.view', 'users.create', 'users.edit', 'users.delete',
'activity.view', 'activity.export'
]);
permissionCache.set('super_admin', allPermissions);
cacheLastUpdated = now;
} else {
logger.error('Failed to refresh permission cache', { error: error.message });
}
}
}
/**
* Check if a role has a specific permission
* @param {string} roleName - Role name to check
* @param {string} permissionName - Permission name to check
* @returns {Promise<boolean>}
*/
async function roleHasPermission(roleName, permissionName) {
await refreshPermissionCache();
const rolePerms = permissionCache.get(roleName);
return rolePerms ? rolePerms.has(permissionName) : false;
}
/**
* Check if user has any of the specified permissions
* @param {number} userId - User ID to check
* @param {string[]} permissions - Array of permission names
* @returns {Promise<boolean>}
*/
async function userHasAnyPermission(userId, permissions) {
const user = await db('admin_users')
.join('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', userId)
.select('roles.name as role_name')
.first();
if (!user) return false;
for (const perm of permissions) {
if (await roleHasPermission(user.role_name, perm)) {
return true;
}
}
return false;
}
/**
* Check if user has all specified permissions
* @param {number} userId - User ID to check
* @param {string[]} permissions - Array of permission names
* @returns {Promise<boolean>}
*/
async function userHasAllPermissions(userId, permissions) {
const user = await db('admin_users')
.join('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', userId)
.select('roles.name as role_name')
.first();
if (!user) return false;
for (const perm of permissions) {
if (!(await roleHasPermission(user.role_name, perm))) {
return false;
}
}
return true;
}
/**
* Middleware factory: require specific permission(s)
* @param {string|string[]} permissions - Permission name(s) required
* @param {object} options - { requireAll: boolean }
* @returns {Function} Express middleware
*/
function requirePermission(permissions, options = { requireAll: false }) {
const permArray = Array.isArray(permissions) ? permissions : [permissions];
return async (req, res, next) => {
try {
if (!req.admin || !req.admin.id) {
throw new ForbiddenError('Authentication required');
}
const hasPermission = options.requireAll
? await userHasAllPermissions(req.admin.id, permArray)
: await userHasAnyPermission(req.admin.id, permArray);
if (!hasPermission) {
logger.warn('Permission denied', {
userId: req.admin.id,
username: req.admin.username,
requiredPermissions: permArray,
path: req.path,
method: req.method
});
throw new ForbiddenError('Insufficient permissions');
}
next();
} catch (error) {
if (error instanceof ForbiddenError) {
return res.status(403).json({ error: error.message, code: 'FORBIDDEN' });
}
next(error);
}
};
}
/**
* Middleware: require super_admin role
* @returns {Function} Express middleware
*/
function requireSuperAdmin() {
return async (req, res, next) => {
try {
if (!req.admin || !req.admin.id) {
throw new ForbiddenError('Authentication required');
}
const user = await db('admin_users')
.join('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', req.admin.id)
.select('roles.name as role_name')
.first();
if (!user || user.role_name !== 'super_admin') {
logger.warn('Super admin access denied', {
userId: req.admin.id,
username: req.admin.username,
path: req.path,
method: req.method
});
throw new ForbiddenError('Super Admin access required');
}
next();
} catch (error) {
if (error instanceof ForbiddenError) {
return res.status(403).json({ error: error.message, code: 'FORBIDDEN' });
}
next(error);
}
};
}
/**
* Get user's permissions for client
* @param {number} userId - User ID
* @returns {Promise<{role: object|null, permissions: string[]}>}
*/
async function getUserPermissions(userId) {
const user = await db('admin_users')
.join('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', userId)
.select('roles.name as role_name', 'roles.display_name as role_display_name')
.first();
if (!user) return { role: null, permissions: [] };
await refreshPermissionCache();
const permissions = permissionCache.get(user.role_name) || new Set();
return {
role: {
name: user.role_name,
displayName: user.role_display_name
},
permissions: Array.from(permissions)
};
}
/**
* Clear permission cache (useful for testing or when permissions change)
*/
function clearPermissionCache() {
permissionCache.clear();
cacheLastUpdated = 0;
}
module.exports = {
requirePermission,
requireSuperAdmin,
getUserPermissions,
userHasAnyPermission,
userHasAllPermissions,
roleHasPermission,
refreshPermissionCache,
clearPermissionCache
};
+75
View File
@@ -0,0 +1,75 @@
/**
* Accept Invitation Routes (Public)
* Handles invitation token validation and account creation
*/
const express = require('express');
const { body, param } = require('express-validator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const userManagementService = require('../services/userManagementService');
const router = express.Router();
/**
* GET /:token
* Validate invitation token
* Public endpoint - no auth required
*/
router.get('/:token', [
param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token')
], handleAsync(async (req, res) => {
validateRequest(req);
const invitation = await userManagementService.validateInvitationToken(req.params.token);
if (!invitation) {
return res.status(404).json({ error: 'Invalid or expired invitation' });
}
res.json({
valid: true,
email: invitation.email,
role: invitation.role_name,
expiresAt: invitation.expires_at
});
}));
/**
* POST /:token
* Accept invitation and create account
* Public endpoint - no auth required
*/
router.post('/:token', [
param('token').isLength({ min: 64, max: 64 }).withMessage('Invalid invitation token'),
body('username')
.trim()
.isLength({ min: 3, max: 50 })
.withMessage('Username must be 3-50 characters')
.matches(/^[a-zA-Z0-9_-]+$/)
.withMessage('Username can only contain letters, numbers, underscores, and hyphens'),
body('password')
.isLength({ min: 12 })
.withMessage('Password must be at least 12 characters')
.custom((value) => {
const validation = validatePasswordStrength(value);
if (!validation.isValid) {
throw new Error(validation.messages.join(', '));
}
return true;
})
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await userManagementService.acceptInvitation({
token: req.params.token,
username: req.body.username,
password: req.body.password
});
successResponse(res, {
message: 'Account created successfully. You can now log in.',
email: result.email
}, 201);
}));
module.exports = router;
+6 -5
View File
@@ -4,12 +4,13 @@ const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
const router = express.Router();
// Get all archived events
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
@@ -81,7 +82,7 @@ router.get('/', adminAuth, async (req, res) => {
});
// Get single archive details
router.get('/:id', adminAuth, async (req, res) => {
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -137,7 +138,7 @@ router.get('/:id', adminAuth, async (req, res) => {
});
// Restore archive
router.post('/:id/restore', adminAuth, async (req, res) => {
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -300,7 +301,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
});
// Download archive
router.get('/:id/download', adminAuth, async (req, res) => {
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -349,7 +350,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
});
// Delete archive permanently
router.delete('/:id', adminAuth, async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
+22 -21
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const fs = require('fs').promises;
@@ -12,7 +13,7 @@ const S3StorageAdapter = require('../services/storage/s3Storage');
const router = express.Router();
// Get backup configuration
router.get('/config', adminAuth, async (req, res) => {
router.get('/config', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
@@ -35,7 +36,7 @@ router.get('/config', adminAuth, async (req, res) => {
});
// Update backup configuration
router.put('/config', adminAuth, async (req, res) => {
router.put('/config', adminAuth, requirePermission('backup.create'), async (req, res) => {
try {
const updates = req.body;
@@ -97,7 +98,7 @@ router.put('/config', adminAuth, async (req, res) => {
});
// Get backup status and history
router.get('/status', adminAuth, async (req, res) => {
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const status = await getBackupStatus(limit);
@@ -110,7 +111,7 @@ router.get('/status', adminAuth, async (req, res) => {
});
// Trigger manual backup
router.post('/run', adminAuth, async (req, res) => {
router.post('/run', adminAuth, requirePermission('backup.create'), async (req, res) => {
try {
// Check if backup is already running
const status = await getBackupStatus();
@@ -131,7 +132,7 @@ router.post('/run', adminAuth, async (req, res) => {
});
// Get backup run details
router.get('/runs/:id', adminAuth, async (req, res) => {
router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { id } = req.params;
@@ -160,7 +161,7 @@ router.get('/runs/:id', adminAuth, async (req, res) => {
});
// Get file states (for debugging/monitoring)
router.get('/files', adminAuth, async (req, res) => {
router.get('/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { page = 1, limit = 50, search = '' } = req.query;
const offset = (page - 1) * limit;
@@ -195,7 +196,7 @@ router.get('/files', adminAuth, async (req, res) => {
});
// Clean up old backup runs
router.delete('/cleanup', adminAuth, async (req, res) => {
router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
try {
const { days = 30 } = req.body;
@@ -209,7 +210,7 @@ router.delete('/cleanup', adminAuth, async (req, res) => {
});
// Test backup destination connectivity
router.post('/test-connection', adminAuth, async (req, res) => {
router.post('/test-connection', adminAuth, requirePermission('backup.create'), async (req, res) => {
try {
const { destination_type, ...config } = req.body;
@@ -334,7 +335,7 @@ router.post('/test-connection', adminAuth, async (req, res) => {
});
// Get backup manifest for a specific backup run
router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupRunId } = req.params;
const result = await getBackupManifest(backupRunId);
@@ -351,7 +352,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
});
// Validate a backup manifest
router.post('/manifest/validate', adminAuth, async (req, res) => {
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath } = req.body;
@@ -373,7 +374,7 @@ router.post('/manifest/validate', adminAuth, async (req, res) => {
});
// Download backup manifest
router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
router.get('/manifest/:backupRunId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupRunId } = req.params;
const { format = 'json' } = req.query;
@@ -404,7 +405,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
});
// Get manifest for specific backup
router.get('/manifests/:backupId', adminAuth, async (req, res) => {
router.get('/manifests/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupId } = req.params;
const result = await getBackupManifest(backupId);
@@ -421,7 +422,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
});
// Download manifest file
router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupId } = req.params;
const { format = 'json' } = req.query;
@@ -452,7 +453,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
});
// Validate a manifest
router.post('/manifests/validate', adminAuth, async (req, res) => {
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath, manifestData } = req.body;
@@ -481,7 +482,7 @@ router.post('/manifests/validate', adminAuth, async (req, res) => {
});
// List S3 buckets
router.get('/s3/buckets', adminAuth, async (req, res) => {
router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const config = await getBackupConfig();
@@ -513,7 +514,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
});
// List files in S3 backup location
router.get('/s3/files', adminAuth, async (req, res) => {
router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
const config = await getBackupConfig();
@@ -550,7 +551,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
});
// Clean up old S3 backups
router.delete('/s3/cleanup', adminAuth, async (req, res) => {
router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), async (req, res) => {
try {
const { retentionDays = 30, dryRun = false } = req.body;
const config = await getBackupConfig();
@@ -612,7 +613,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
});
// Test S3 upload functionality
router.post('/s3/test-upload', adminAuth, async (req, res) => {
router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), async (req, res) => {
try {
const config = await getBackupConfig();
@@ -664,7 +665,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
});
// Download entire backup
router.get('/download/:backupId', adminAuth, async (req, res) => {
router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { backupId } = req.params;
@@ -752,7 +753,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
});
// Get current file checksums
router.get('/checksums', adminAuth, async (req, res) => {
router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { path: targetPath = '', recursive = true } = req.query;
const checksums = {};
@@ -821,7 +822,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
});
// Estimate backup size before running
router.post('/estimate', adminAuth, async (req, res) => {
router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { includeArchived = true } = req.body;
+4 -3
View File
@@ -2,10 +2,11 @@ const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
// Get all CMS pages
router.get('/pages', adminAuth, async (req, res) => {
router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => {
try {
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
res.json(pages);
@@ -16,7 +17,7 @@ router.get('/pages', adminAuth, async (req, res) => {
});
// Get a single CMS page
router.get('/pages/:slug', adminAuth, async (req, res) => {
router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, res) => {
try {
const { slug } = req.params;
const page = await db('cms_pages').where('slug', slug).first();
@@ -33,7 +34,7 @@ router.get('/pages/:slug', adminAuth, async (req, res) => {
});
// Update a CMS page
router.put('/pages/:slug', adminAuth, [
router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
body('title_en').optional().isString(),
body('title_de').optional().isString(),
body('content_en').optional().isString(),
+6 -5
View File
@@ -3,10 +3,11 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
// Get all global categories
router.get('/global', adminAuth, async (req, res) => {
router.get('/global', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const categories = await db('photo_categories')
.where('is_global', formatBoolean(true))
@@ -20,7 +21,7 @@ router.get('/global', adminAuth, async (req, res) => {
});
// Get categories for a specific event (global + event-specific)
router.get('/event/:eventId', adminAuth, async (req, res) => {
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { eventId } = req.params;
@@ -40,7 +41,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
});
// Create a new category
router.post('/', adminAuth, [
router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required'),
body('slug').optional(),
body('is_global').optional().isBoolean(),
@@ -104,7 +105,7 @@ router.post('/', adminAuth, [
});
// Update a category
router.put('/:id', adminAuth, [
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required')
], async (req, res) => {
try {
@@ -149,7 +150,7 @@ router.put('/:id', adminAuth, [
});
// Delete a category
router.delete('/:id', adminAuth, async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { id } = req.params;
+6 -5
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const { body, param, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
@@ -15,7 +16,7 @@ const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_temp
* GET /admin/css-templates
* Get all CSS templates
*/
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('branding.view'), async (req, res) => {
try {
const templates = await withRetry(() =>
db('css_templates').orderBy('slot_number')
@@ -31,7 +32,7 @@ router.get('/', adminAuth, async (req, res) => {
* GET /admin/css-templates/enabled
* Get only enabled templates (for event form dropdown)
*/
router.get('/enabled', adminAuth, async (req, res) => {
router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req, res) => {
try {
const templates = await withRetry(() =>
db('css_templates')
@@ -50,7 +51,7 @@ router.get('/enabled', adminAuth, async (req, res) => {
* GET /admin/css-templates/:slotNumber
* Get a specific template by slot number
*/
router.get('/:slotNumber', adminAuth, [
router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
param('slotNumber').isInt({ min: 1, max: 3 })
], async (req, res) => {
try {
@@ -81,7 +82,7 @@ router.get('/:slotNumber', adminAuth, [
* PUT /admin/css-templates/:slotNumber
* Update a template
*/
router.put('/:slotNumber', adminAuth, [
router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
param('slotNumber').isInt({ min: 1, max: 3 }),
body('name').optional().isString().isLength({ max: 50 }),
body('css_content').optional().isString(),
@@ -158,7 +159,7 @@ router.put('/:slotNumber', adminAuth, [
* POST /admin/css-templates/:slotNumber/reset
* Reset template to default (only for slot 1)
*/
router.post('/:slotNumber/reset', adminAuth, [
router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'), [
param('slotNumber').isInt({ min: 1, max: 1 }).withMessage('Only template 1 can be reset to default')
], async (req, res) => {
try {
+5 -4
View File
@@ -1,12 +1,13 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const router = express.Router();
// Get dashboard statistics
router.get('/stats', adminAuth, async (req, res) => {
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
// Get active events count
const activeEvents = await db('events')
@@ -106,7 +107,7 @@ router.get('/stats', adminAuth, async (req, res) => {
});
// Get recent activity
router.get('/activity', adminAuth, async (req, res) => {
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
@@ -144,7 +145,7 @@ router.get('/activity', adminAuth, async (req, res) => {
});
// Get system health status
router.get('/health', adminAuth, async (req, res) => {
router.get('/health', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const os = require('os');
@@ -216,7 +217,7 @@ router.get('/health', adminAuth, async (req, res) => {
});
// Get analytics data for charts
router.get('/analytics', adminAuth, async (req, res) => {
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const days = sanitizeDays(req.query.days || 7);
+9 -8
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { databaseBackupService } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
@@ -11,7 +12,7 @@ router.use(adminAuth);
/**
* Get database backup status and configuration
*/
router.get('/status', async (req, res) => {
router.get('/status', requirePermission('backup.view'), async (req, res) => {
try {
// Get configuration
const config = await databaseBackupService.getBackupConfig();
@@ -45,7 +46,7 @@ router.get('/status', async (req, res) => {
/**
* Update database backup configuration
*/
router.put('/config', async (req, res) => {
router.put('/config', requirePermission('backup.create'), async (req, res) => {
try {
const allowedSettings = [
'database_backup_enabled',
@@ -108,7 +109,7 @@ router.put('/config', async (req, res) => {
/**
* Trigger manual database backup
*/
router.post('/backup', async (req, res) => {
router.post('/backup', requirePermission('backup.create'), async (req, res) => {
try {
if (databaseBackupService.isRunning) {
return res.status(409).json({ error: 'Backup already in progress' });
@@ -134,7 +135,7 @@ router.post('/backup', async (req, res) => {
/**
* Get current backup progress
*/
router.get('/progress', async (req, res) => {
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
try {
const progress = databaseBackupService.getProgress();
@@ -151,7 +152,7 @@ router.get('/progress', async (req, res) => {
/**
* Get backup history with pagination
*/
router.get('/history', async (req, res) => {
router.get('/history', requirePermission('backup.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
@@ -183,7 +184,7 @@ router.get('/history', async (req, res) => {
/**
* Delete old backup files
*/
router.delete('/cleanup', async (req, res) => {
router.delete('/cleanup', requirePermission('backup.delete'), async (req, res) => {
try {
const { retentionDays = 30 } = req.body;
@@ -202,7 +203,7 @@ router.delete('/cleanup', async (req, res) => {
/**
* Test database backup configuration
*/
router.post('/test', async (req, res) => {
router.post('/test', requirePermission('backup.create'), async (req, res) => {
try {
const config = await databaseBackupService.getBackupConfig();
@@ -255,7 +256,7 @@ router.post('/test', async (req, res) => {
/**
* Get table checksums
*/
router.get('/checksums', async (req, res) => {
router.get('/checksums', requirePermission('backup.view'), async (req, res) => {
try {
const checksums = await databaseBackupService.getTableChecksums();
+8 -5
View File
@@ -3,10 +3,11 @@ const nodemailer = require('nodemailer');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
// Get email configuration
router.get('/config', adminAuth, async (req, res) => {
router.get('/config', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const config = await db('email_configs').first();
@@ -37,6 +38,7 @@ router.get('/config', adminAuth, async (req, res) => {
// Update email configuration
router.post('/config', [
adminAuth,
requirePermission('email.edit'),
body('smtp_host').notEmpty().withMessage('SMTP host is required'),
body('smtp_port').isInt({ min: 1, max: 65535 }).withMessage('Invalid port number'),
body('from_email').isEmail().withMessage('Invalid from email address')
@@ -100,7 +102,7 @@ router.post('/config', [
});
// Test email configuration
router.post('/test', adminAuth, async (req, res) => {
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
try {
const { test_email } = req.body;
@@ -236,7 +238,7 @@ router.post('/test', adminAuth, async (req, res) => {
});
// Get email templates
router.get('/templates', adminAuth, async (req, res) => {
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const templates = await db('email_templates')
.select('*')
@@ -290,7 +292,7 @@ router.get('/templates', adminAuth, async (req, res) => {
});
// Get single template
router.get('/templates/:key', adminAuth, async (req, res) => {
router.get('/templates/:key', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const template = await db('email_templates')
.where('template_key', req.params.key)
@@ -346,6 +348,7 @@ router.get('/templates/:key', adminAuth, async (req, res) => {
// Update email template
router.put('/templates/:key', [
adminAuth,
requirePermission('email.edit'),
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
@@ -424,7 +427,7 @@ router.put('/templates/:key', [
});
// Preview email template
router.post('/templates/:key/preview', adminAuth, async (req, res) => {
router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const template = await db('email_templates')
.where('template_key', req.params.key)
+3 -2
View File
@@ -6,6 +6,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const eventRenameService = require('../services/eventRenameService');
const router = express.Router();
@@ -13,7 +14,7 @@ const router = express.Router();
* POST /api/admin/events/:eventId/rename
* Rename an event
*/
router.post('/:eventId/rename', adminAuth, [
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
@@ -58,7 +59,7 @@ router.post('/:eventId/rename', adminAuth, [
* POST /api/admin/events/:eventId/validate-rename
* Validate a potential rename without executing it
*/
router.post('/:eventId/validate-rename', adminAuth, [
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
body('newEventName')
.trim()
.isLength({ min: 3, max: 100 })
+2 -1
View File
@@ -3,9 +3,10 @@
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { requirePermission } = require('../middleware/permissions');
// Enhanced event creation with password validation
router.post('/', adminAuth, [
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
+55 -24
View File
@@ -3,6 +3,7 @@ const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
const bcrypt = require('bcrypt');
const crypto = require('crypto');
@@ -102,7 +103,7 @@ const hasCustomerContactColumns = async () => {
};
// Create new event
router.post('/', adminAuth, [
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
@@ -296,6 +297,7 @@ router.post('/', adminAuth, [
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
@@ -375,7 +377,7 @@ router.post('/', adminAuth, [
});
// Get all events with pagination and filters
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
@@ -387,7 +389,12 @@ router.get('/', adminAuth, async (req, res) => {
// Build query
let query = db('events');
// Editor role can only see their own events
if (req.admin.roleName === 'editor') {
query = query.where('created_by', req.admin.id);
}
// Apply search filter
if (search) {
const escapedSearch = escapeLikePattern(search);
@@ -465,13 +472,18 @@ router.get('/', adminAuth, async (req, res) => {
});
// Get single event details
router.get('/:id', adminAuth, async (req, res) => {
router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res) => {
try {
const { id } = req.params;
const event = await db('events')
.where('id', id)
.first();
let query = db('events').where('id', id);
// Editor role can only see their own events
if (req.admin.roleName === 'editor') {
query = query.where('created_by', req.admin.id);
}
const event = await query.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
@@ -525,7 +537,7 @@ router.get('/:id', adminAuth, async (req, res) => {
});
// Update event
router.put('/:id', adminAuth, [
router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
@@ -569,7 +581,8 @@ router.put('/:id', adminAuth, [
throw new Error('Password must be at least 6 characters long');
}
return true;
})
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -659,7 +672,12 @@ router.put('/:id', adminAuth, [
});
// Check if event exists
const event = await db('events').where('id', id).first();
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
@@ -696,7 +714,7 @@ router.put('/:id', adminAuth, [
});
// Delete event
router.delete('/:id', adminAuth, async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
try {
const { id } = req.params;
@@ -777,11 +795,16 @@ router.delete('/:id', adminAuth, async (req, res) => {
});
// Toggle event status
router.post('/:id/toggle-status', adminAuth, async (req, res) => {
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
@@ -812,12 +835,17 @@ router.post('/:id/toggle-status', adminAuth, async (req, res) => {
});
// Reset event password
router.post('/:id/reset-password', adminAuth, async (req, res) => {
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
try {
const { id } = req.params;
const { sendEmail = true } = req.body;
const event = await db('events').where('id', id).first();
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
@@ -874,15 +902,18 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
});
// Resend creation email
router.post('/:id/resend-email', adminAuth, async (req, res) => {
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
try {
const { id } = req.params;
// Get event details
const event = await db('events')
.where('id', id)
.first();
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
@@ -954,7 +985,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
});
// Archive event
router.post('/:id/archive', adminAuth, async (req, res) => {
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
try {
const { id } = req.params;
@@ -985,7 +1016,7 @@ router.post('/:id/archive', adminAuth, async (req, res) => {
});
// Bulk archive events
router.post('/bulk-archive', adminAuth, [
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
body('eventIds').isArray().withMessage('eventIds must be an array'),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
], async (req, res) => {
+3 -2
View File
@@ -2,6 +2,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
const router = express.Router();
// GET /api/admin/external-media/list?path=relative/dir
router.get('/list', adminAuth, async (req, res) => {
router.get('/list', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const relPath = (req.query.path || '').replace(/^\/+/, '');
const result = await list(relPath);
@@ -45,7 +46,7 @@ async function walkDir(dir, baseDir) {
// POST /api/admin/events/:id/import-external
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
router.post('/events/:id/import-external', adminAuth, async (req, res) => {
router.post('/events/:id/import-external', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const eventId = parseInt(req.params.id);
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
+14 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
@@ -13,8 +14,9 @@ const {
} = require('../utils/feedbackValidation');
// Get event feedback settings
router.get('/events/:eventId/feedback-settings',
router.get('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.view'),
validateEventId,
checkValidation,
async (req, res) => {
@@ -39,6 +41,7 @@ router.get('/events/:eventId/feedback-settings',
// Update event feedback settings
router.put('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.edit'),
validateEventId,
validateFeedbackSettings,
checkValidation,
@@ -75,6 +78,7 @@ router.put('/events/:eventId/feedback-settings',
// Get feedback for an event (with filters)
router.get('/events/:eventId/feedback',
adminAuth,
requirePermission('events.view'),
validateEventId,
checkValidation,
async (req, res) => {
@@ -159,6 +163,7 @@ router.get('/events/:eventId/feedback',
// Moderate feedback (approve/hide/reject)
router.put('/feedback/:feedbackId/:action',
adminAuth,
requirePermission('events.edit'),
async (req, res) => {
try {
const { feedbackId, action } = req.params;
@@ -180,6 +185,7 @@ router.put('/feedback/:feedbackId/:action',
// Delete feedback
router.delete('/feedback/:feedbackId',
adminAuth,
requirePermission('events.delete'),
async (req, res) => {
try {
const { feedbackId } = req.params;
@@ -197,6 +203,7 @@ router.delete('/feedback/:feedbackId',
// Get feedback analytics for an event
router.get('/events/:eventId/feedback-analytics',
adminAuth,
requirePermission('events.view'),
validateEventId,
checkValidation,
async (req, res) => {
@@ -296,6 +303,7 @@ router.get('/events/:eventId/feedback-analytics',
// Export feedback data
router.get('/events/:eventId/feedback/export',
adminAuth,
requirePermission('events.view'),
validateEventId,
checkValidation,
async (req, res) => {
@@ -324,6 +332,7 @@ router.get('/events/:eventId/feedback/export',
// Get pending moderation items (across all events)
router.get('/feedback/pending-moderation',
adminAuth,
requirePermission('events.view'),
async (req, res) => {
try {
const pending = await feedbackService.getPendingModeration();
@@ -338,6 +347,7 @@ router.get('/feedback/pending-moderation',
// Word filter management
router.get('/word-filters',
adminAuth,
requirePermission('settings.view'),
async (req, res) => {
try {
const filters = await feedbackModeration.getAllWordFilters();
@@ -351,6 +361,7 @@ router.get('/word-filters',
router.post('/word-filters',
adminAuth,
requirePermission('settings.edit'),
validateWordFilter,
checkValidation,
async (req, res) => {
@@ -378,6 +389,7 @@ router.post('/word-filters',
router.put('/word-filters/:id',
adminAuth,
requirePermission('settings.edit'),
async (req, res) => {
try {
const { id } = req.params;
@@ -395,6 +407,7 @@ router.put('/word-filters/:id',
router.delete('/word-filters/:id',
adminAuth,
requirePermission('settings.edit'),
async (req, res) => {
try {
const { id } = req.params;
+9 -8
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
@@ -9,7 +10,7 @@ const router = express.Router();
/**
* Get image security settings
*/
router.get('/settings', adminAuth, async (req, res) => {
router.get('/settings', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
@@ -46,7 +47,7 @@ router.get('/settings', adminAuth, async (req, res) => {
/**
* Update image security settings
*/
router.put('/settings', adminAuth, async (req, res) => {
router.put('/settings', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const updates = req.body;
@@ -97,7 +98,7 @@ router.put('/settings', adminAuth, async (req, res) => {
/**
* Get security monitoring dashboard data
*/
router.get('/dashboard', adminAuth, async (req, res) => {
router.get('/dashboard', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { timeframe = '24h' } = req.query;
@@ -202,7 +203,7 @@ router.get('/dashboard', adminAuth, async (req, res) => {
/**
* Get detailed security logs
*/
router.get('/logs', adminAuth, async (req, res) => {
router.get('/logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const {
page = 1,
@@ -271,7 +272,7 @@ router.get('/logs', adminAuth, async (req, res) => {
/**
* Get image access logs for a specific event
*/
router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
router.get('/events/:eventId/access-logs', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { eventId } = req.params;
const { page = 1, limit = 50 } = req.query;
@@ -321,7 +322,7 @@ router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => {
/**
* Block/unblock suspicious IPs
*/
router.post('/block-ip', adminAuth, async (req, res) => {
router.post('/block-ip', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { ip, action = 'block' } = req.body;
@@ -367,7 +368,7 @@ router.post('/block-ip', adminAuth, async (req, res) => {
/**
* Clear security logs older than specified time
*/
router.delete('/logs/cleanup', adminAuth, async (req, res) => {
router.delete('/logs/cleanup', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { olderThan = '30d' } = req.body;
@@ -424,7 +425,7 @@ router.delete('/logs/cleanup', adminAuth, async (req, res) => {
/**
* Export security data for analysis
*/
router.get('/export', adminAuth, async (req, res) => {
router.get('/export', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { format = 'json', timeframe = '7d' } = req.query;
+5 -4
View File
@@ -1,10 +1,11 @@
const express = require('express');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
// Get notifications (unread activity logs)
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { limit = 20, includeRead = false } = req.query;
@@ -64,7 +65,7 @@ router.get('/', adminAuth, async (req, res) => {
});
// Mark notification as read
router.put('/:id/read', adminAuth, async (req, res) => {
router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { id } = req.params;
@@ -82,7 +83,7 @@ router.put('/:id/read', adminAuth, async (req, res) => {
});
// Mark all notifications as read
router.put('/read-all', adminAuth, async (req, res) => {
router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
await db('activity_logs')
.whereNull('read_at')
@@ -98,7 +99,7 @@ router.put('/read-all', adminAuth, async (req, res) => {
});
// Delete old notifications (older than 30 days and read)
router.delete('/clear-old', adminAuth, async (req, res) => {
router.delete('/clear-old', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
// Use database-agnostic date calculation
const thirtyDaysAgo = new Date();
+5 -4
View File
@@ -8,6 +8,7 @@ const router = express.Router();
const { body, query, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
const { PhotoExportService } = require('../services/photoExportService');
@@ -17,7 +18,7 @@ const exportService = new PhotoExportService();
* GET /admin/photos/:eventId/filtered
* Get filtered photos with pagination
*/
router.get('/:eventId/filtered', adminAuth, [
router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
query('has_likes').optional().isBoolean(),
@@ -131,7 +132,7 @@ router.get('/:eventId/filtered', adminAuth, [
* GET /admin/photos/:eventId/filter-summary
* Get just the summary counts for filter UI
*/
router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
@@ -153,7 +154,7 @@ router.get('/:eventId/filter-summary', adminAuth, async (req, res) => {
* POST /admin/photos/:eventId/export
* Export selected or filtered photos
*/
router.post('/:eventId/export', adminAuth, [
router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), [
body('photo_ids').optional().isArray(),
body('photo_ids.*').optional().isInt(),
body('filter').optional().isObject(),
@@ -213,7 +214,7 @@ router.post('/:eventId/export', adminAuth, [
* GET /admin/photos/export-formats
* Get available export format options
*/
router.get('/export-formats', adminAuth, (req, res) => {
router.get('/export-formats', adminAuth, requirePermission('photos.view'), (req, res) => {
res.json({
success: true,
data: PhotoExportService.getFormatOptions()
+46 -33
View File
@@ -4,6 +4,7 @@ const path = require('path');
const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
@@ -109,7 +110,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
// Upload photos for an event
// Max file count is configurable via general settings
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
@@ -176,21 +177,23 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Parse category_id to number if provided
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
// Determine photo type from category_id parameter (for backwards compatibility)
// Determine photo type and category name
let photoType = 'individual'; // default
let categoryName = 'individual';
if (parsedCategoryId === 1 || category_id === 'collage') {
photoType = 'collage';
categoryName = 'collages';
} else if (parsedCategoryId === 2 || category_id === 'individual') {
photoType = 'individual';
categoryName = 'individual';
}
// For backwards compatibility, accept string values
if (category_id === 'collage') {
// Look up the actual category from database if provided
if (parsedCategoryId && !isNaN(parsedCategoryId)) {
const category = await db('photo_categories').where({ id: parsedCategoryId }).first();
if (category) {
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
// Use category slug for type determination
if (category.slug === 'collage' || category.slug === 'collages') {
photoType = 'collage';
}
}
} else if (category_id === 'collage') {
// For backwards compatibility, accept string values
photoType = 'collage';
categoryName = 'collages';
}
@@ -256,6 +259,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
path: relativePath,
thumbnail_path: null, // Will generate after successful commit
type: photoType,
category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size // Use actual file size from stat
};
@@ -420,7 +424,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
});
// Delete a photo
router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -477,7 +481,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
});
// Update a photo (e.g., change category)
router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
@@ -517,7 +521,15 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
.where({ id: photoId, event_id: eventId })
.update(updateData);
res.json({ message: 'Photo updated successfully' });
// Fetch and return the updated photo
const updatedPhoto = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
res.json({
message: 'Photo updated successfully',
photo: updatedPhoto
});
} catch (error) {
console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' });
@@ -525,7 +537,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
});
// Bulk delete photos
router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds } = req.body;
@@ -593,7 +605,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => {
});
// Bulk update photos
router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds, updates } = req.body;
@@ -651,7 +663,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
});
// Download a photo
router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) => {
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -683,14 +695,15 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
});
// Get all photos for an event
router.get('/:eventId/photos', adminAuth, async (req, res) => {
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId } = req.params;
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
let query = db('photos')
.where({ 'photos.event_id': eventId })
.select('photos.*');
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
// Filter by type (individual/collage) - category_id maps to type
if (category_id !== undefined) {
@@ -747,9 +760,9 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
// Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_slug: photo.type,
category_id: photo.category_id || photo.type,
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
category_slug: photo.pc_slug || photo.type,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Feedback data
@@ -767,7 +780,7 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
});
// Serve photo with admin authentication
router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -804,7 +817,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
});
// Serve thumbnail with admin authentication
router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -844,7 +857,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
});
// Debug endpoint to check photo existence
router.get('/:eventId/debug', adminAuth, async (req, res) => {
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId } = req.params;
@@ -870,7 +883,7 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
// ============================================
// Initialize a chunked upload
router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const { eventId } = req.params;
const { filename, fileSize, mimeType, totalChunks } = req.body;
@@ -908,7 +921,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
});
// Upload a chunk
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, async (req, res) => {
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const { uploadId, chunkIndex } = req.params;
@@ -929,7 +942,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, a
});
// Complete chunked upload and process the file
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req, res) => {
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
try {
const { eventId, uploadId } = req.params;
const { category_id } = req.body;
@@ -971,7 +984,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req
});
// Get upload status
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, res) => {
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { uploadId } = req.params;
@@ -989,7 +1002,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, r
});
// Abort chunked upload
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, async (req, res) => {
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
try {
const { uploadId } = req.params;
+149 -17
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { restoreService } = require('../services/restoreService');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { body, query, validationResult } = require('express-validator');
const logger = require('../utils/logger');
const { db } = require('../database/db');
@@ -16,10 +17,36 @@ const fs = require('fs').promises;
// Apply admin authentication to all routes
router.use(adminAuth);
/**
* Transform frontend S3 config to backend format
* Frontend sends: s3Endpoint, s3Bucket, s3AccessKey, s3SecretKey, s3Region
* Backend expects: endpoint, bucket, accessKeyId, secretAccessKey, region
*/
function transformS3Config(body) {
if (body.s3Config) {
// Already in correct format
return body.s3Config;
}
// Check if frontend sent flat S3 config fields
if (body.s3Endpoint || body.s3Bucket || body.s3AccessKey || body.s3SecretKey) {
return {
endpoint: body.s3Endpoint,
bucket: body.s3Bucket,
accessKeyId: body.s3AccessKey,
secretAccessKey: body.s3SecretKey,
region: body.s3Region || 'us-east-1',
forcePathStyle: body.s3ForcePathStyle !== false
};
}
return null;
}
/**
* Get restore service status and history
*/
router.get('/status', async (req, res) => {
router.get('/status', requirePermission('backup.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const history = await restoreService.getRestoreHistory(limit);
@@ -47,7 +74,7 @@ router.get('/status', async (req, res) => {
/**
* Validate restore request
*/
router.post('/validate', [
router.post('/validate', requirePermission('backup.restore'), [
body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
@@ -63,18 +90,38 @@ router.post('/validate', [
}
try {
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
// Perform dry run validation
const result = await restoreService.restore({
...req.body,
source: req.body.source,
manifestPath: req.body.manifestPath,
restoreType: req.body.restoreType,
selectedItems: req.body.selectedItems,
s3Config,
dryRun: true,
force: false
});
// Transform spaceCheck to match frontend expected format
const spaceCheck = result.spaceCheck ? {
sufficient: result.spaceCheck.hasEnoughSpace,
required: result.spaceCheck.requiredBytes,
available: result.spaceCheck.availableBytes,
requiredFormatted: result.spaceCheck.requiredFormatted,
availableFormatted: result.spaceCheck.availableFormatted,
// Keep original fields for backwards compatibility
hasEnoughSpace: result.spaceCheck.hasEnoughSpace,
requiredBytes: result.spaceCheck.requiredBytes,
availableBytes: result.spaceCheck.availableBytes
} : null;
res.json({
success: true,
data: {
validation: result.validation,
spaceCheck: result.spaceCheck,
spaceCheck,
logs: result.logs
}
});
@@ -82,7 +129,7 @@ router.post('/validate', [
logger.error('Restore validation failed:', error);
res.status(400).json({
success: false,
error: 'Restore validation failed',
error: error.message || 'Restore validation failed',
logs: restoreService.restoreLog
});
}
@@ -91,7 +138,7 @@ router.post('/validate', [
/**
* Start restore operation
*/
router.post('/start', [
router.post('/start', requirePermission('backup.restore'), [
body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
@@ -135,19 +182,28 @@ router.post('/start', [
// Log restore attempt
logger.warn('Restore operation started', {
user: req.user.email,
user: req.admin.email,
ip: req.ip,
restoreType: req.body.restoreType,
source: req.body.source
});
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
// Start restore in background
restoreService.restore({
...req.body,
source: req.body.source,
manifestPath: req.body.manifestPath,
restoreType: req.body.restoreType,
selectedItems: req.body.selectedItems,
skipPreBackup: req.body.skipPreBackup,
force: req.body.force,
s3Config,
dryRun: false,
operator: {
type: 'manual',
userId: req.user.id,
userId: req.admin.id,
ip: req.ip
}
}).catch(error => {
@@ -160,9 +216,10 @@ router.post('/start', [
});
} catch (error) {
logger.error('Failed to start restore:', error);
logger.error('Error stack:', error.stack);
res.status(500).json({
success: false,
error: 'Failed to start restore operation'
error: error.message || 'Failed to start restore operation'
});
}
});
@@ -170,7 +227,7 @@ router.post('/start', [
/**
* Get current restore progress
*/
router.get('/progress', async (req, res) => {
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
try {
const progress = restoreService.getProgress();
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
@@ -195,7 +252,7 @@ router.get('/progress', async (req, res) => {
/**
* Get restore run details
*/
router.get('/run/:id', async (req, res) => {
router.get('/run/:id', requirePermission('backup.view'), async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
@@ -250,7 +307,7 @@ router.get('/run/:id', async (req, res) => {
/**
* Get restore run report
*/
router.get('/run/:id/report', async (req, res) => {
router.get('/run/:id/report', requirePermission('backup.view'), async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
@@ -289,7 +346,7 @@ router.get('/run/:id/report', async (req, res) => {
/**
* List available backups for restore
*/
router.get('/available-backups', async (req, res) => {
router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
try {
const backups = [];
@@ -349,10 +406,85 @@ router.get('/available-backups', async (req, res) => {
}
});
/**
* List backups for restore (POST version for frontend compatibility)
* Accepts source type in request body
*/
router.post('/list-backups', requirePermission('backup.view'), async (req, res) => {
try {
const { source } = req.body; // 'local', 's3', or undefined for all
const backups = [];
// Get backup configuration
const backupConfig = await getBackupConfig();
// Get database backups from backup_runs table
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(20);
for (const run of backupRuns) {
const isS3 = run.manifest_path.startsWith('s3://');
const backupType = isS3 ? 's3' : 'local';
// Filter by source if specified
if (source && source !== backupType) {
continue;
}
backups.push({
id: run.id,
type: backupType,
name: `Backup from ${new Date(run.completed_at).toLocaleString()}`,
path: run.manifest_path,
manifest_path: run.manifest_path,
manifestId: run.manifest_id,
manifestPath: run.manifest_path,
size: parseInt(run.total_size_bytes) || 0,
total_size: parseInt(run.total_size_bytes) || 0,
total_size_bytes: parseInt(run.total_size_bytes) || 0,
filesCount: run.files_backed_up || 0,
files_backed_up: run.files_backed_up || 0,
duration: run.duration_seconds,
duration_seconds: run.duration_seconds,
// Frontend expects snake_case date fields
created_at: run.completed_at,
completed_at: run.completed_at,
started_at: run.started_at,
// camelCase aliases
completedAt: run.completed_at,
startedAt: run.started_at,
// Backup metadata
status: run.status,
backup_type: run.backup_type,
backupType: run.backup_type,
backup_mode: run.backup_mode,
backupMode: run.backup_mode,
app_version: run.app_version,
appVersion: run.app_version
});
}
res.json({
success: true,
data: backups,
source: source || 'all'
});
} catch (error) {
logger.error('Failed to list backups for restore:', error);
res.status(500).json({
success: false,
error: 'Failed to list backups for restore'
});
}
});
/**
* Get restore settings
*/
router.get('/settings', async (req, res) => {
router.get('/settings', requirePermission('backup.view'), async (req, res) => {
try {
const settings = await getRestoreSettings();
res.json({
@@ -371,7 +503,7 @@ router.get('/settings', async (req, res) => {
/**
* Update restore settings
*/
router.put('/settings', [
router.put('/settings', requirePermission('backup.restore'), [
body('restore_allow_force').optional().isBoolean(),
body('restore_require_pre_backup').optional().isBoolean(),
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
+25 -20
View File
@@ -6,6 +6,7 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const { clearSettingsCache } = require('../services/rateLimitService');
const {
@@ -92,7 +93,7 @@ const faviconUpload = multer({
});
// Get all settings
router.get('/', adminAuth, async (req, res) => {
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const settings = await db('app_settings').select('*');
@@ -120,7 +121,7 @@ router.get('/', adminAuth, async (req, res) => {
});
// Get settings by type
router.get('/:type', adminAuth, async (req, res) => {
router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { type } = req.params;
const settings = await db('app_settings')
@@ -151,7 +152,7 @@ router.get('/:type', adminAuth, async (req, res) => {
});
// Get password complexity settings for frontend
router.get('/password/complexity', adminAuth, async (req, res) => {
router.get('/password/complexity', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation');
@@ -172,7 +173,7 @@ router.get('/password/complexity', adminAuth, async (req, res) => {
});
// Update branding settings
router.put('/branding', adminAuth, async (req, res) => {
router.put('/branding', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const {
company_name,
@@ -313,7 +314,7 @@ router.put('/branding', adminAuth, async (req, res) => {
});
// Upload logo
router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.single('logo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No logo file uploaded' });
@@ -325,8 +326,12 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
.first();
if (oldLogoSetting && oldLogoSetting.setting_value) {
const oldPath = JSON.parse(oldLogoSetting.setting_value);
try {
// Handle both JSON-serialized and legacy raw path values
let oldPath = oldLogoSetting.setting_value;
if (oldPath.startsWith('"')) {
oldPath = JSON.parse(oldPath);
}
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old logo:', error);
@@ -354,13 +359,13 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
await db('app_settings')
.insert({
setting_key: 'branding_logo_url',
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
updated_at: new Date()
});
@@ -375,7 +380,7 @@ router.post('/logo', adminAuth, upload.single('logo'), async (req, res) => {
});
// Upload watermark logo
router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'), async (req, res) => {
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
@@ -437,7 +442,7 @@ router.post('/branding/watermark-logo', adminAuth, upload.single('watermarkLogo'
});
// Update theme settings
router.put('/theme', adminAuth, async (req, res) => {
router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const themeSettings = req.body;
@@ -474,7 +479,7 @@ router.put('/theme', adminAuth, async (req, res) => {
});
// Update general settings
router.put('/general', adminAuth, async (req, res) => {
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = { ...req.body };
let uploadLimitTouched = false;
@@ -573,7 +578,7 @@ router.put('/general', adminAuth, async (req, res) => {
});
// Update security settings
router.put('/security', adminAuth, async (req, res) => {
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
@@ -612,7 +617,7 @@ router.put('/security', adminAuth, async (req, res) => {
});
// Update analytics settings
router.put('/analytics', adminAuth, async (req, res) => {
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
@@ -649,7 +654,7 @@ router.put('/analytics', adminAuth, async (req, res) => {
});
// Get storage info
router.get('/storage/info', adminAuth, async (req, res) => {
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Get total storage used
const totalStorage = await db('photos')
@@ -877,7 +882,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
});
// Upload favicon endpoint
router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req, res) => {
router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUpload.single('favicon'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No favicon file provided' });
@@ -890,13 +895,13 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
await db('app_settings')
.insert({
setting_key: 'branding_favicon_url',
setting_value: faviconUrl,
setting_value: JSON.stringify(faviconUrl),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: faviconUrl,
setting_value: JSON.stringify(faviconUrl),
updated_at: new Date()
});
@@ -915,7 +920,7 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
});
// Update rate limit settings
router.put('/security/rate-limit', adminAuth, [
router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit'), [
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
@@ -979,7 +984,7 @@ router.put('/security/rate-limit', adminAuth, [
});
// Get default public site template
router.get('/public-site/default', adminAuth, async (req, res) => {
router.get('/public-site/default', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const defaults = await getDefaultPublicSitePayload();
@@ -1000,7 +1005,7 @@ router.get('/public-site/default', adminAuth, async (req, res) => {
});
// Reset public site template to defaults
router.post('/public-site/reset', adminAuth, async (req, res) => {
router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const entries = [
{
+4 -3
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
const router = express.Router();
// Get system version
router.get('/version', adminAuth, async (req, res) => {
router.get('/version', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Read backend version from package.json
let backendVersion = '1.0.0';
@@ -35,7 +36,7 @@ router.get('/version', adminAuth, async (req, res) => {
});
// Get comprehensive system status
router.get('/status', adminAuth, async (req, res) => {
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Database size - check if PostgreSQL or SQLite
let dbSize = 0;
@@ -170,7 +171,7 @@ router.get('/status', adminAuth, async (req, res) => {
});
// Get database statistics
router.get('/database', adminAuth, async (req, res) => {
router.get('/database', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Get table info
const tables = [
+5 -4
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail } = require('../services/imageProcessor');
const path = require('path');
const fs = require('fs').promises;
@@ -10,7 +11,7 @@ const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Get thumbnail settings
router.get('/settings', adminAuth, async (req, res) => {
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const settings = await db('app_settings')
.whereIn('key', [
@@ -42,7 +43,7 @@ router.get('/settings', adminAuth, async (req, res) => {
});
// Update thumbnail settings
router.put('/settings', adminAuth, async (req, res) => {
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { width, height, fit, quality, format } = req.body;
@@ -91,7 +92,7 @@ router.put('/settings', adminAuth, async (req, res) => {
});
// Regenerate all thumbnails with new settings
router.post('/regenerate', adminAuth, async (req, res) => {
router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.body; // Optional: regenerate for specific event only
@@ -164,7 +165,7 @@ router.post('/regenerate', adminAuth, async (req, res) => {
});
// Get regeneration status
router.get('/regenerate/status', adminAuth, async (req, res) => {
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
// Count photos with and without thumbnails
const totalPhotos = await db('photos').count('id as count').first();
+208
View File
@@ -0,0 +1,208 @@
/**
* 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
};
}
/**
* Transform invitation object from snake_case (DB) to camelCase (API)
*/
function transformInvitation(invitation) {
return {
id: invitation.id,
email: invitation.email,
expiresAt: invitation.expires_at,
createdAt: invitation.created_at,
roleName: invitation.role_name,
invitedBy: invitation.invited_by
};
}
/**
* 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: invitations.map(transformInvitation) });
}));
/**
* POST /invite
* Create invitation
* Requires: users.create permission
*/
router.post('/invite', [
adminAuth,
requirePermission('users.create'),
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('role_id').isInt({ min: 1 }).withMessage('Role ID is required')
], handleAsync(async (req, res) => {
validateRequest(req);
const invitation = await userManagementService.createInvitation({
email: req.body.email,
roleId: req.body.role_id,
invitedById: req.admin.id
});
successResponse(res, { invitation }, 201);
}));
/**
* DELETE /invitations/:id
* Cancel invitation
* Requires: users.create permission
*/
router.delete('/invitations/:id', [
adminAuth,
requirePermission('users.create'),
param('id').isInt({ min: 1 }).withMessage('Valid invitation ID is required')
], handleAsync(async (req, res) => {
validateRequest(req);
await userManagementService.cancelInvitation(parseInt(req.params.id), req.admin.id);
successResponse(res, { message: 'Invitation cancelled' });
}));
/**
* GET /:id
* Get single user
* Requires: users.view permission
*/
router.get('/:id', [
adminAuth,
requirePermission('users.view'),
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
], handleAsync(async (req, res) => {
validateRequest(req);
const user = await userManagementService.getAdminUserById(parseInt(req.params.id));
res.json({ user: transformUser(user) });
}));
/**
* PUT /:id
* Update user
* Requires: users.edit permission
*/
router.put('/:id', [
adminAuth,
requirePermission('users.edit'),
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required'),
body('username').optional().trim().isLength({ min: 3, max: 50 }).withMessage('Username must be 3-50 characters'),
body('email').optional().isEmail().normalizeEmail().withMessage('Valid email is required'),
body('role_id').optional().isInt({ min: 1 }).withMessage('Valid role ID is required'),
body('is_active').optional().isBoolean().withMessage('is_active must be boolean')
], handleAsync(async (req, res) => {
validateRequest(req);
const user = await userManagementService.updateAdminUser(
parseInt(req.params.id),
req.body,
req.admin.id
);
successResponse(res, { user: transformUser(user), message: 'User updated successfully' });
}));
/**
* POST /:id/deactivate
* Deactivate user
* Requires: users.delete permission
*/
router.post('/:id/deactivate', [
adminAuth,
requirePermission('users.delete'),
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
], handleAsync(async (req, res) => {
validateRequest(req);
await userManagementService.deactivateAdminUser(parseInt(req.params.id), req.admin.id);
successResponse(res, { message: 'User deactivated successfully' });
}));
/**
* POST /:id/reset-password
* Reset user password
* Requires: super_admin role
*/
router.post('/:id/reset-password', [
adminAuth,
requireSuperAdmin(),
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
], handleAsync(async (req, res) => {
validateRequest(req);
const result = await userManagementService.resetAdminPassword(parseInt(req.params.id), req.admin.id);
successResponse(res, { message: 'Password reset email sent', ...result });
}));
module.exports = router;
+26 -13
View File
@@ -70,52 +70,65 @@ router.post('/admin/login', [
logger.warn('Suspicious login pattern detected', { username, ipAddress });
}
// Fetch admin with role information
const admin = await db('admin_users')
.where({ username })
.orWhere({ email: username })
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.username', username)
.orWhere('admin_users.email', username)
.select(
'admin_users.*',
'roles.name as role_name',
'roles.display_name as role_display_name'
)
.first();
// Use generic error to prevent user enumeration
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
if (!admin.is_active) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
// Successful login
await trackSuccessfulLogin(username, ipAddress, userAgent);
// Update last login and login metadata
await db('admin_users').where('id', admin.id).update({
await db('admin_users').where('id', admin.id).update({
last_login: new Date(),
last_login_ip: ipAddress
});
// Generate token with additional claims
const token = jwt.sign({
// Generate token with additional claims including role
const token = jwt.sign({
id: admin.id,
username: admin.username,
type: 'admin',
role: admin.role_name, // Add role to JWT
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, token);
// Include role in response
res.json({
token,
user: {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false
mustChangePassword: admin.must_change_password || false,
role: admin.role_name ? {
name: admin.role_name,
displayName: admin.role_display_name
} : null
}
});
} catch (error) {
+1
View File
@@ -58,6 +58,7 @@ router.get('/', async (req, res) => {
branding_logo_display_header: settingsObject.branding_logo_display_header !== false,
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
branding_hide_powered_by: settingsObject.branding_hide_powered_by === true,
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
+64 -16
View File
@@ -710,7 +710,7 @@ async function saveManifestToS3(manifest, manifestFileName, config, result) {
return manifestPath;
}
async function runBackupInternal() {
async function runBackupInternal(isManual = false) {
if (isRunning) {
logger.warn('Backup already running, skipping');
return;
@@ -722,21 +722,30 @@ async function runBackupInternal() {
try {
const config = await resolveConfigWithFallback();
if (!config || !normalizeBoolean(config.backup_enabled)) {
logger.info('Backup is disabled, skipping');
// For scheduled backups, check if backup is enabled
// Manual backups should always be allowed (just need valid destination config)
if (!isManual && (!config || !normalizeBoolean(config.backup_enabled))) {
logger.info('Scheduled backup is disabled, skipping');
return;
}
// For manual backups, just ensure we have a destination configured
if (!config || !config.backup_destination_type) {
logger.warn('Backup destination not configured');
throw new Error('Backup destination not configured. Please configure backup settings first.');
}
const schemaVersion = await getCurrentSchemaVersion();
const [insertedId] = await db('backup_runs').insert({
const insertResult = await db('backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: 'scheduled',
backup_type: isManual ? 'manual' : 'scheduled',
app_version: packageJson.version,
node_version: process.version,
db_schema_version: schemaVersion
});
runId = insertedId;
}).returning('id');
runId = insertResult[0]?.id || insertResult[0];
const files = await service.getFilesToBackup(config.backup_include_archived);
logger.info(`Found ${files.length} files to check for backup`);
@@ -820,11 +829,17 @@ async function runBackupInternal() {
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
manifest_info: manifestSummary ? JSON.stringify({ summary: manifestSummary }) : null,
statistics: JSON.stringify({
// Use snake_case for frontend compatibility
files_processed: result.backedUpCount,
total_size: result.backedUpSize,
total_files_checked: files.length,
average_file_size: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
destination: destinationType,
// Keep camelCase for backward compatibility
totalFilesChecked: files.length,
filesBackedUp: result.backedUpCount,
totalSize: result.backedUpSize,
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
destination: destinationType
averageFileSize: result.backedUpCount ? Math.round(result.backedUpSize / result.backedUpCount) : 0
})
});
@@ -922,27 +937,59 @@ function stopBackupService() {
async function triggerManualBackup() {
logger.info('Starting manual backup');
await service.runBackup();
await service.runBackup(true); // Pass flag to indicate manual backup
}
async function getBackupStatus(limit = 10) {
try {
const runs = await db('backup_runs')
const rawRuns = await db('backup_runs')
.orderBy('started_at', 'desc')
.limit(limit);
// Transform runs to add frontend-compatible field aliases
const runs = rawRuns.map(run => {
// Parse and transform statistics to snake_case for frontend compatibility
let statistics = run.statistics;
if (statistics) {
// Handle both string (SQLite) and object (PostgreSQL JSONB) types
let stats = statistics;
if (typeof statistics === 'string') {
try {
stats = JSON.parse(statistics);
} catch (e) {
stats = {};
}
}
// Add snake_case aliases for frontend
statistics = {
...stats,
files_processed: stats.filesBackedUp || stats.files_processed || 0,
total_size: stats.totalSize || stats.total_size || 0,
total_files_checked: stats.totalFilesChecked || stats.total_files_checked || 0,
average_file_size: stats.averageFileSize || stats.average_file_size || 0
};
}
return {
...run,
created_at: run.started_at, // Alias for frontend compatibility
statistics
};
});
const lastRun = runs[0];
let manifestValid = false;
if (lastRun && lastRun.manifest_path) {
try {
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
if (backupManifest.validateManifest) {
backupManifest.validateManifest(manifest);
// Use validateBackupManifest which handles both local and S3 paths
const result = await validateBackupManifest(lastRun.manifest_path);
manifestValid = result.valid;
if (!result.valid) {
logger.warn('Manifest validation failed:', result.error);
}
manifestValid = true;
} catch (error) {
logger.warn('Manifest validation failed:', error);
logger.warn('Manifest validation failed:', error.message);
}
}
@@ -951,6 +998,7 @@ async function getBackupStatus(limit = 10) {
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
lastRun: lastRun ? { ...lastRun, manifestValid } : null,
recentRuns: runs,
recentBackups: runs, // Alias for frontend compatibility
nextScheduledRun: getNextScheduledRun()
};
} catch (error) {
+3
View File
@@ -153,6 +153,9 @@ async function processTemplate(template, variables, language = 'en') {
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
if (processedVariables.expires_at) {
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
+45 -17
View File
@@ -75,14 +75,15 @@ class RestoreService {
this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) });
// Create restore run record
const [runId] = await db('restore_runs').insert({
const result = await db('restore_runs').insert({
started_at: startTime,
status: 'running',
restore_type: options.restoreType,
source: options.source,
manifest_path: options.manifestPath,
is_dry_run: options.dryRun || false
});
}).returning('id');
const runId = Array.isArray(result) ? (result[0]?.id || result[0]) : result;
restoreRun = { id: runId };
@@ -105,7 +106,8 @@ class RestoreService {
if (validation.warnings.length > 0) {
this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings });
if (!options.force) {
// Only block actual restores (not dry runs/validations) on warnings
if (!options.force && !options.dryRun) {
throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`);
}
}
@@ -400,29 +402,55 @@ class RestoreService {
* Check available disk space
*/
async checkDiskSpace(manifest, options) {
const { statvfs } = require('fs');
const statvfsAsync = promisify(statvfs);
try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const stats = await statvfsAsync(storagePath);
const blockSize = stats.bsize || stats.f_bsize || 4096;
const availableBytes = stats.bavail * blockSize;
// Calculate required space (with 20% buffer)
let requiredBytes = 0;
if (options.restoreType === 'full' || options.restoreType === 'files') {
requiredBytes = manifest.files.total_size * 1.2;
requiredBytes = (manifest.files?.total_size || 0) * 1.2;
}
if (options.restoreType === 'full' || options.restoreType === 'database') {
requiredBytes += (manifest.database.size || 0) * 1.2;
requiredBytes += (manifest.database?.size || 0) * 1.2;
}
// Try to get disk space using df command (works on Linux and macOS)
let availableBytes = 0;
let diskCheckSucceeded = false;
try {
const { exec } = require('child_process');
const execAsync = promisify(exec);
// Use root path as fallback if storage path doesn't exist yet
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
const parsed = parseInt(stdout.trim());
if (!isNaN(parsed) && parsed > 0) {
availableBytes = parsed * 1024; // Convert from KB to bytes
diskCheckSucceeded = true;
}
} catch (dfError) {
this.log('warn', 'Could not determine available disk space', { error: dfError.message });
}
// If disk check failed, return optimistic result
if (!diskCheckSucceeded) {
return {
hasEnoughSpace: true,
availableBytes: null, // null indicates unknown
requiredBytes,
availableFormatted: 'Unknown',
requiredFormatted: this.formatBytes(requiredBytes)
};
}
// Add space for pre-restore backup
if (!options.skipPreBackup) {
const currentUsage = await this.calculateCurrentStorageUsage();
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
try {
const currentUsage = await this.calculateCurrentStorageUsage();
requiredBytes += currentUsage * 1.1; // 10% buffer for backup
} catch (e) {
// Ignore errors calculating current usage
}
}
return {
@@ -434,11 +462,11 @@ class RestoreService {
};
} catch (error) {
// Fallback for systems without statvfs
// Fallback for any errors
this.log('warn', 'Could not check disk space', { error: error.message });
return {
hasEnoughSpace: true, // Assume we have space if we can't check
availableBytes: 0,
availableBytes: null,
requiredBytes: 0,
availableFormatted: 'Unknown',
requiredFormatted: 'Unknown'
+16 -6
View File
@@ -76,12 +76,22 @@ class S3StorageAdapter extends stream.EventEmitter {
// Add custom endpoint if provided (for S3-compatible services)
if (this.config.endpoint) {
s3Config.endpoint = this.config.endpoint;
// For MinIO and other S3-compatible services
if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) {
s3Config.endpoint = `https://${this.config.endpoint}`;
} else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) {
s3Config.endpoint = `http://${this.config.endpoint}`;
let endpoint = this.config.endpoint;
// Only add protocol if endpoint doesn't already have one
const hasProtocol = endpoint.startsWith('http://') || endpoint.startsWith('https://');
if (!hasProtocol) {
// Add protocol based on sslEnabled setting
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
}
s3Config.endpoint = endpoint;
// For S3-compatible services with custom endpoints, force path style
// This is required for MinIO and when using IP addresses
if (!s3Config.forcePathStyle) {
s3Config.forcePathStyle = true;
logger.info('Automatically enabling forcePathStyle for custom S3 endpoint');
}
}
@@ -0,0 +1,440 @@
/**
* User Management Service for Admin Users
* Handles invitations, user CRUD, and role management
*/
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateReadablePassword } = require('../utils/passwordGenerator');
const { getBcryptRounds } = require('../utils/passwordValidation');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors');
/**
* Create a new admin user invitation
* @param {object} params - { email, roleId, invitedById }
* @returns {Promise<object>} Created invitation details
*/
async function createInvitation({ email, roleId, invitedById }) {
// Check if email already exists
const existingUser = await db('admin_users').where('email', email).first();
if (existingUser) {
throw new ConflictError('User with this email already exists', 'email');
}
// Check for pending invitation
const pendingInvite = await db('admin_invitations')
.where('email', email)
.whereNull('accepted_at')
.where('expires_at', '>', new Date())
.first();
if (pendingInvite) {
throw new ConflictError('Pending invitation already exists for this email', 'email');
}
// Validate role exists
const role = await db('roles').where('id', roleId).first();
if (!role) {
throw new NotFoundError('Role', roleId);
}
// Generate secure invitation token (64 characters hex = 32 bytes)
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
const [invitationId] = await db('admin_invitations').insert({
email,
token,
role_id: roleId,
invited_by: invitedById,
expires_at: expiresAt,
created_at: new Date()
}).returning('id');
const id = invitationId?.id || invitationId;
// Queue invitation email
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
await queueEmail(null, email, 'admin_invitation', {
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
role_name: role.display_name,
expires_at: expiresAt.toISOString()
});
await logActivity('admin_invitation_created',
{ email, roleId, roleName: role.display_name },
null,
{ type: 'admin', id: invitedById, name: 'system' }
);
logger.info('Admin invitation created', { email, roleId, invitedById });
return { id, email, token, role: role.display_name, expiresAt };
}
/**
* Accept an invitation and create the admin user
* @param {object} params - { token, username, password }
* @returns {Promise<object>} Created user details
*/
async function acceptInvitation({ token, username, password }) {
const invitation = await db('admin_invitations')
.where('token', token)
.whereNull('accepted_at')
.where('expires_at', '>', new Date())
.first();
if (!invitation) {
throw new ValidationError('Invalid or expired invitation');
}
// Check username availability
const existingUsername = await db('admin_users').where('username', username).first();
if (existingUsername) {
throw new ConflictError('Username already taken', 'username');
}
// Check email not taken (race condition protection)
const existingEmail = await db('admin_users').where('email', invitation.email).first();
if (existingEmail) {
throw new ConflictError('Email already registered', 'email');
}
// Hash password
const passwordHash = await bcrypt.hash(password, getBcryptRounds());
// Create user in transaction
const result = await db.transaction(async (trx) => {
const [userId] = await trx('admin_users').insert({
username,
email: invitation.email,
password_hash: passwordHash,
role_id: invitation.role_id,
created_by: invitation.invited_by,
is_active: formatBoolean(true),
must_change_password: formatBoolean(false),
invite_accepted_at: new Date(),
created_at: new Date(),
updated_at: new Date()
}).returning('id');
const id = userId?.id || userId;
// Mark invitation as accepted
await trx('admin_invitations')
.where('id', invitation.id)
.update({
accepted_at: new Date(),
accepted_user_id: id
});
return id;
});
await logActivity('admin_invitation_accepted',
{ userId: result, email: invitation.email },
null,
{ type: 'system', id: null, name: 'system' }
);
logger.info('Admin invitation accepted', {
userId: result,
email: invitation.email,
invitationId: invitation.id
});
return { userId: result, email: invitation.email };
}
/**
* Get all admin users with their roles
* @returns {Promise<object[]>}
*/
async function getAllAdminUsers() {
return db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.leftJoin('admin_users as creator', 'creator.id', 'admin_users.created_by')
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.is_active',
'admin_users.last_login',
'admin_users.last_login_ip',
'admin_users.created_at',
'admin_users.updated_at',
'roles.id as role_id',
'roles.name as role_name',
'roles.display_name as role_display_name',
'creator.username as created_by_username'
)
.orderBy('admin_users.created_at', 'desc');
}
/**
* Get single admin user by ID
* @param {number} id - User ID
* @returns {Promise<object>}
*/
async function getAdminUserById(id) {
const user = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where('admin_users.id', id)
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'admin_users.is_active',
'admin_users.last_login',
'admin_users.last_login_ip',
'admin_users.created_at',
'admin_users.updated_at',
'roles.id as role_id',
'roles.name as role_name',
'roles.display_name as role_display_name'
)
.first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
return user;
}
/**
* Update admin user
* @param {number} id - User ID to update
* @param {object} updates - Fields to update
* @param {number} updatedById - ID of user making the update
* @returns {Promise<object>} Updated user
*/
async function updateAdminUser(id, updates, updatedById) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
const allowedUpdates = {};
if (updates.username !== undefined) {
const existing = await db('admin_users')
.where('username', updates.username)
.whereNot('id', id)
.first();
if (existing) {
throw new ConflictError('Username already taken', 'username');
}
allowedUpdates.username = updates.username;
}
if (updates.email !== undefined) {
const existing = await db('admin_users')
.where('email', updates.email)
.whereNot('id', id)
.first();
if (existing) {
throw new ConflictError('Email already in use', 'email');
}
allowedUpdates.email = updates.email;
}
if (updates.role_id !== undefined) {
const role = await db('roles').where('id', updates.role_id).first();
if (!role) {
throw new NotFoundError('Role', updates.role_id);
}
allowedUpdates.role_id = updates.role_id;
}
if (updates.is_active !== undefined) {
allowedUpdates.is_active = formatBoolean(updates.is_active);
}
allowedUpdates.updated_at = new Date();
await db('admin_users').where('id', id).update(allowedUpdates);
await logActivity('admin_user_updated',
{ userId: id, changes: Object.keys(allowedUpdates) },
null,
{ type: 'admin', id: updatedById, name: 'system' }
);
return getAdminUserById(id);
}
/**
* Deactivate admin user
* @param {number} id - User ID to deactivate
* @param {number} deactivatedById - ID of user performing deactivation
*/
async function deactivateAdminUser(id, deactivatedById) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
// Prevent self-deactivation
if (id === deactivatedById) {
throw new ValidationError('Cannot deactivate your own account');
}
// Check if this is the last super_admin
const superAdminRole = await db('roles').where('name', 'super_admin').first();
if (user.role_id === superAdminRole?.id) {
const superAdminCount = await db('admin_users')
.where('role_id', superAdminRole.id)
.where('is_active', formatBoolean(true))
.count('id as count')
.first();
if (Number(superAdminCount?.count) <= 1) {
throw new ValidationError('Cannot deactivate the last Super Admin');
}
}
await db('admin_users').where('id', id).update({
is_active: formatBoolean(false),
updated_at: new Date()
});
await logActivity('admin_user_deactivated',
{ userId: id, username: user.username },
null,
{ type: 'admin', id: deactivatedById, name: 'system' }
);
logger.info('Admin user deactivated', { userId: id, deactivatedById });
}
/**
* Reset admin user password (generates new password)
* @param {number} id - User ID
* @param {number} resetById - ID of user performing reset
* @returns {Promise<object>} Result with email and status
*/
async function resetAdminPassword(id, resetById) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
}
const newPassword = generateReadablePassword();
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
await db('admin_users').where('id', id).update({
password_hash: passwordHash,
must_change_password: formatBoolean(true),
password_changed_at: new Date(),
updated_at: new Date()
});
// Queue password reset email
await queueEmail(null, user.email, 'admin_password_reset', {
username: user.username,
new_password: newPassword
});
await logActivity('admin_password_reset',
{ userId: id, username: user.username },
null,
{ type: 'admin', id: resetById, name: 'system' }
);
logger.info('Admin password reset', { userId: id, resetById });
return { email: user.email, passwordSent: true };
}
/**
* Get all roles
* @returns {Promise<object[]>}
*/
async function getAllRoles() {
return db('roles')
.select('id', 'name', 'display_name', 'description', 'is_system', 'priority')
.orderBy('priority', 'desc');
}
/**
* Get pending invitations
* @returns {Promise<object[]>}
*/
async function getPendingInvitations() {
return db('admin_invitations')
.join('roles', 'roles.id', 'admin_invitations.role_id')
.join('admin_users', 'admin_users.id', 'admin_invitations.invited_by')
.whereNull('admin_invitations.accepted_at')
.where('admin_invitations.expires_at', '>', new Date())
.select(
'admin_invitations.id',
'admin_invitations.email',
'admin_invitations.expires_at',
'admin_invitations.created_at',
'roles.display_name as role_name',
'admin_users.username as invited_by'
)
.orderBy('admin_invitations.created_at', 'desc');
}
/**
* Cancel/delete an invitation
* @param {number} id - Invitation ID
* @param {number} cancelledById - ID of user cancelling
*/
async function cancelInvitation(id, cancelledById) {
const invitation = await db('admin_invitations').where('id', id).first();
if (!invitation) {
throw new NotFoundError('Invitation', id);
}
await db('admin_invitations').where('id', id).del();
await logActivity('admin_invitation_cancelled',
{ invitationId: id, email: invitation.email },
null,
{ type: 'admin', id: cancelledById, name: 'system' }
);
logger.info('Admin invitation cancelled', { invitationId: id, cancelledById });
}
/**
* Validate an invitation token
* @param {string} token - Invitation token
* @returns {Promise<object|null>} Invitation details if valid
*/
async function validateInvitationToken(token) {
const invitation = await db('admin_invitations')
.join('roles', 'roles.id', 'admin_invitations.role_id')
.where('admin_invitations.token', token)
.whereNull('admin_invitations.accepted_at')
.where('admin_invitations.expires_at', '>', new Date())
.select(
'admin_invitations.email',
'admin_invitations.expires_at',
'roles.display_name as role_name'
)
.first();
return invitation || null;
}
module.exports = {
createInvitation,
acceptInvitation,
getAllAdminUsers,
getAdminUserById,
updateAdminUser,
deactivateAdminUser,
resetAdminPassword,
getAllRoles,
getPendingInvitations,
cancelInvitation,
validateInvitationToken
};
+35 -17
View File
@@ -7,7 +7,8 @@ host="${DB_HOST:-postgres}"
port="${DB_PORT:-5432}"
user="${DB_USER:-picpeak}"
target_db="${DB_NAME:-picpeak}"
default_db="${DB_CHECK_DB:-postgres}"
# Use target database for checks - the picpeak user may not have access to 'postgres' database
default_db="${DB_CHECK_DB:-$target_db}"
sanitize_identifier() {
printf '%s' "$1" | sed "s/'/''/g"
@@ -15,27 +16,44 @@ sanitize_identifier() {
echo "Waiting for PostgreSQL at $host:$port..."
# Wait for PostgreSQL server to accept connections (using the default database)
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c '\q' >/dev/null 2>&1; do
>&2 echo "PostgreSQL is unavailable - sleeping"
# First, wait for PostgreSQL server to be reachable
max_attempts=30
attempt=0
while [ $attempt -lt $max_attempts ]; do
if PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$target_db" -c '\q' >/dev/null 2>&1; then
>&2 echo "PostgreSQL is up - database \"$target_db\" is accessible."
break
fi
# If target DB doesn't work, try connecting to 'postgres' or 'template1' to create it
if PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "template1" -c '\q' >/dev/null 2>&1; then
>&2 echo "PostgreSQL is up - checking if database \"$target_db\" needs to be created..."
# Check if database exists
db_exists=$(PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "template1" -tAc "SELECT 1 FROM pg_database WHERE datname = '$(sanitize_identifier "$target_db")'" 2>/dev/null || echo 0)
if [ "$db_exists" != "1" ]; then
>&2 echo "Database \"$target_db\" not found. Attempting to create..."
if PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "template1" -c "CREATE DATABASE \"$target_db\";" >/dev/null 2>&1; then
>&2 echo "Database \"$target_db\" created successfully."
else
>&2 echo "Warning: Could not create database. It may already exist or user lacks permissions."
fi
fi
break
fi
attempt=$((attempt + 1))
>&2 echo "PostgreSQL is unavailable - sleeping (attempt $attempt/$max_attempts)"
sleep 2
done
>&2 echo "PostgreSQL is up - verifying target database \"$target_db\""
# Ensure the target database exists (helps when volumes are reused or DB_NAME is customised)
db_exists=$(PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -tAc "SELECT 1 FROM pg_database WHERE datname = '$(sanitize_identifier "$target_db")'" 2>/dev/null || echo 0)
if [ "$db_exists" != "1" ]; then
>&2 echo "Database \"$target_db\" not found. Attempting to create..."
if ! PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c "CREATE DATABASE \"$target_db\";" >/dev/null 2>&1; then
>&2 echo "Failed to create database \"$target_db\". Please ensure it exists and is accessible."
exit 1
fi
>&2 echo "Database \"$target_db\" created successfully."
if [ $attempt -eq $max_attempts ]; then
>&2 echo "Failed to connect to PostgreSQL after $max_attempts attempts."
exit 1
fi
# Wait until the target database itself is ready to accept connections
# Final verification - wait for target database to accept connections
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$target_db" -c '\q' >/dev/null 2>&1; do
>&2 echo "Waiting for database \"$target_db\" to accept connections..."
sleep 2
+3 -3
View File
@@ -7,7 +7,7 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${NODE_ENV:-production}
- PORT=3001
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
@@ -43,12 +43,12 @@ services:
- ./backup:/backup
- ./storage:/app/storage
ports:
- "${BACKEND_PORT:-3001}:3001"
- "${BACKEND_PORT:-3001}:3000"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3001/health"]
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3000/health"]
interval: 30s
timeout: 10s
retries: 3
+5 -5
View File
@@ -30,13 +30,13 @@ COPY . .
# Build the application
RUN npm run build
# Production stage (use Alpine with patched libpng)
FROM nginx:1.27-alpine3.21
# Production stage (use Alpine with patched libpng/c-ares)
FROM nginx:1.27-alpine
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
# Upgrade all packages to fix security vulnerabilities
# This ensures libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
# and c-ares >= 1.34.5 (fixes CVE-2025-31498)
RUN apk upgrade --no-cache
# Ensure libpng includes CVE fixes (pull patched version from edge)
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/main 'libpng>=1.6.51-r0'
# Install runtime dependencies
RUN apk add --no-cache curl
+7 -6
View File
@@ -43,7 +43,7 @@ server {
# API proxy
location /api {
proxy_pass http://backend:3001;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
@@ -61,7 +61,7 @@ server {
# Photo serving proxy
location /photos {
proxy_pass http://backend:3001;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -75,7 +75,7 @@ server {
# Thumbnail serving proxy
location /thumbnails {
proxy_pass http://backend:3001;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -88,8 +88,9 @@ server {
}
# Uploads serving proxy (logos, favicons, watermarks)
location /uploads {
proxy_pass http://backend:3001;
# ^~ modifier stops regex matching, ensuring uploads are proxied not served locally
location ^~ /uploads {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -103,7 +104,7 @@ server {
# Delegate root requests to backend for public landing page handling
location = / {
proxy_pass http://backend:3001/;
proxy_pass http://backend:3000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
+50 -61
View File
@@ -1088,9 +1088,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
"integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz",
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1604,9 +1604,9 @@
]
},
"node_modules/@tanstack/query-core": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.11.tgz",
"integrity": "sha512-f9z/nXhCgWDF4lHqgIE30jxLe4sYv15QodfdPDKYAk7nAEjNcndy4dHz3ezhdUaR23BpWa4I2EH4/DZ0//Uf8A==",
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz",
"integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1614,12 +1614,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "5.90.11",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.11.tgz",
"integrity": "sha512-3uyzz01D1fkTLXuxF3JfoJoHQMU2fxsfJwE+6N5hHy0dVNoZOvwKP8Z2k7k1KDeD54N20apcJnG75TBAStIrBA==",
"version": "5.90.16",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz",
"integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.90.11"
"@tanstack/query-core": "5.90.16"
},
"funding": {
"type": "github",
@@ -1688,9 +1688,9 @@
}
},
"node_modules/@testing-library/react": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
"version": "16.3.1",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz",
"integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2935,9 +2935,9 @@
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.22",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.22.tgz",
"integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
"integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==",
"dev": true,
"funding": [
{
@@ -2955,10 +2955,9 @@
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.27.0",
"caniuse-lite": "^1.0.30001754",
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001760",
"fraction.js": "^5.3.4",
"normalize-range": "^0.1.2",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
@@ -2991,9 +2990,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.31",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
"integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
"version": "2.9.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz",
"integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3038,9 +3037,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -3059,11 +3058,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
"electron-to-chromium": "^1.5.249",
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.1.4"
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -3116,9 +3115,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001757",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
"integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"dev": true,
"funding": [
{
@@ -3522,9 +3521,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.0.tgz",
"integrity": "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==",
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -3545,9 +3544,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.262",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
"integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -3680,9 +3679,9 @@
}
},
"node_modules/eslint": {
"version": "9.39.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"version": "9.39.2",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -3693,7 +3692,7 @@
"@eslint/config-helpers": "^0.4.2",
"@eslint/core": "^0.17.0",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "9.39.1",
"@eslint/js": "9.39.2",
"@eslint/plugin-kit": "^0.4.1",
"@humanfs/node": "^0.16.6",
"@humanwhocodes/module-importer": "^1.0.1",
@@ -3754,9 +3753,9 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
"integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz",
"integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@@ -4325,9 +4324,9 @@
}
},
"node_modules/i18next": {
"version": "25.6.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.3.tgz",
"integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==",
"version": "25.7.3",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
"funding": [
{
"type": "individual",
@@ -5022,16 +5021,6 @@
"node": ">=0.10.0"
}
},
"node_modules/normalize-range": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nwsapi": {
"version": "2.2.22",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
@@ -6517,9 +6506,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.1.15",
"version": "2.2.2",
"type": "module",
"scripts": {
"dev": "vite",
+10 -4
View File
@@ -10,9 +10,9 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
import { LegalPage } from './pages/public/LegalPage';
import {
AdminLoginPage,
AdminDashboard,
import {
AdminLoginPage,
AdminDashboard,
EventsListPage,
CreateEventPage,
EventDetailsPage,
@@ -23,8 +23,10 @@ import {
BrandingPage,
SettingsPage,
BackupManagement,
CMSPage
CMSPage,
UserManagementPage
} from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -128,10 +130,14 @@ function App() {
<Route path="settings" element={<SettingsPage />} />
<Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPage />} />
<Route path="users" element={<UserManagementPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
{/* Public invitation acceptance page */}
<Route path="/invite/:token" element={<AcceptInvitePage />} />
{/* Public legal pages */}
<Route path="/impressum" element={<LegalPage />} />
<Route path="/datenschutz" element={<LegalPage />} />
@@ -1,11 +1,13 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { AdminAuthProvider } from '../../contexts';
import { AdminAuthProvider, PermissionsProvider } from '../../contexts';
export const AdminAuthWrapper: React.FC = () => {
return (
<AdminAuthProvider>
<Outlet />
<PermissionsProvider>
<Outlet />
</PermissionsProvider>
</AdminAuthProvider>
);
};
+39 -31
View File
@@ -1,21 +1,23 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Calendar,
Mail,
Archive,
BarChart3,
import {
LayoutDashboard,
Calendar,
Mail,
Archive,
BarChart3,
Settings,
X,
Palette,
FileText,
HardDrive
HardDrive,
Users
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
import { usePermissions } from '../../contexts/PermissionsContext';
interface AdminSidebarProps {
isOpen: boolean;
@@ -26,23 +28,32 @@ interface NavItem {
nameKey: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
permission?: string;
}
const navigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3 },
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings },
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view' },
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
const location = useLocation();
const { t } = useTranslation();
const { hasPermission } = usePermissions();
// Filter navigation items based on permissions
const filteredNavigation = navigation.filter(item => {
if (!item.permission) return true;
return hasPermission(item.permission);
});
return (
<div
@@ -66,7 +77,7 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
{/* Navigation */}
<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 ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
@@ -90,14 +101,16 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
})}
</nav>
{/* Bottom section - sticky to bottom */}
<div className="flex-shrink-0">
{/* Version Info */}
<VersionInfo />
{/* Storage Info */}
<StorageInfo />
</div>
{/* Bottom section - sticky to bottom (only for users with settings.view permission) */}
{hasPermission('settings.view') && (
<div className="flex-shrink-0">
{/* Version Info */}
<VersionInfo />
{/* Storage Info */}
<StorageInfo />
</div>
)}
</div>
</div>
);
@@ -111,14 +124,9 @@ const StorageInfo: React.FC = () => {
refetchInterval: 60000 // Refresh every minute
});
// Don't render anything while loading or if data failed to load
if (!storageInfo) {
return (
<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>
);
return null;
}
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="flex justify-between">
<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 className="flex justify-between">
<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>
{!validationResult.spaceCheck.sufficient && (
{validationResult.spaceCheck.sufficient === false && (
<p className="text-red-600 text-xs mt-2">
<AlertCircle className="inline h-3 w-3 mr-1" />
{t('backup.restore.confirmation.spaceCheck.insufficient')}
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info } from 'lucide-react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
// import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -16,6 +17,10 @@ interface ThemeCustomizerEnhancedProps {
hideActions?: boolean;
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
isApplying?: boolean;
// CSS Template props
cssTemplates?: EnabledTemplate[];
cssTemplateId?: number | null;
onCssTemplateChange?: (templateId: number | null) => void;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
@@ -38,7 +43,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
showGalleryLayouts = true,
hideActions = false,
onApply,
isApplying = false
isApplying = false,
cssTemplates,
cssTemplateId,
onCssTemplateChange
}) => {
const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
@@ -566,11 +574,67 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</div>
</Card>
{/* Custom CSS */}
{/* CSS Template Selector - only show if templates are provided */}
{cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<FileCode className="w-5 h-5" />
{t('branding.cssTemplate', 'CSS Template')}
</h3>
<p className="text-sm text-neutral-600 mb-4">
{t('branding.cssTemplateDescription', 'Select a pre-built CSS template to apply application-wide styling to this gallery. Templates can be managed in Settings > CSS Templates.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* No template option */}
<button
onClick={() => onCssTemplateChange(null)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
!cssTemplateId
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{t('branding.noTemplate', 'No Template')}</span>
{!cssTemplateId && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 mt-1 block">
{t('branding.noTemplateDescription', 'Use only theme settings without a CSS template')}
</span>
</button>
{/* Template options */}
{cssTemplates.map((template) => (
<button
key={template.id}
onClick={() => onCssTemplateChange(template.id)}
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
cssTemplateId === template.id
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{template.name}</span>
{cssTemplateId === template.id && (
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
)}
</div>
<span className="text-xs text-neutral-600 mt-1 block">
{t('branding.templateSlot', 'Slot {{slot}}', { slot: template.slot_number })}
</span>
</button>
))}
</div>
</Card>
)}
{/* Event-specific Custom CSS */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('branding.customCSS')}
{t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
</h3>
{/* Collapsible Instructions Panel */}
@@ -4,13 +4,15 @@ import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { useTranslation } from 'react-i18next';
interface ThemeEditorModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (theme: ThemeConfig, presetName: string) => void;
onSave: (theme: ThemeConfig, presetName: string, cssTemplateId: number | null) => void;
currentTheme: ThemeConfig | string;
currentCssTemplateId?: number | null;
eventName: string;
}
@@ -28,12 +30,29 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
onClose,
onSave,
currentTheme,
currentCssTemplateId,
eventName
}) => {
const { t } = useTranslation();
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
const [presetName, setPresetName] = useState<string>('default');
const [previewLayout, setPreviewLayout] = useState<GalleryLayoutType | undefined>(undefined);
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
const [cssTemplateId, setCssTemplateId] = useState<number | null>(currentCssTemplateId ?? null);
// Fetch CSS templates when modal opens
useEffect(() => {
if (isOpen) {
cssTemplatesService.getEnabledTemplates()
.then(setCssTemplates)
.catch(err => console.error('Failed to load CSS templates:', err));
}
}, [isOpen]);
// Update cssTemplateId when prop changes
useEffect(() => {
setCssTemplateId(currentCssTemplateId ?? null);
}, [currentCssTemplateId]);
useEffect(() => {
if (currentTheme) {
@@ -82,7 +101,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
};
const handleSave = () => {
onSave(theme, presetName);
onSave(theme, presetName, cssTemplateId);
onClose();
};
@@ -128,6 +147,9 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
cssTemplates={cssTemplates}
cssTemplateId={cssTemplateId}
onCssTemplateChange={setCssTemplateId}
/>
</div>
@@ -456,7 +456,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onClick={() => {
setShowFeedback(!showFeedback);
}}
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
className="relative p-2 bg-black/40 hover:bg-black/60 rounded-full border border-white/40 transition-colors"
aria-label="Toggle feedback"
title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`}
>
@@ -164,7 +164,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
});
} catch (_) {}
}}
className={`hover:bg-white/20 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
className={`bg-black/30 hover:bg-black/50 rounded-full border border-white/40 ${likedIds.has(currentPhoto.id) ? 'text-red-400' : 'text-white'}`}
title="Like photo"
aria-pressed={likedIds.has(currentPhoto.id)}
>
@@ -176,7 +176,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
variant="ghost"
size="sm"
onClick={() => { onOpenPhotoWithFeedback?.(currentIndex); }}
className="text-white hover:bg-white/20"
className="text-white bg-black/30 hover:bg-black/50 rounded-full border border-white/40"
title="Comment"
aria-label="Comment on photo"
>
@@ -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 };
+2 -1
View File
@@ -3,4 +3,5 @@ export { AdminAuthProvider, useAdminAuth } from './AdminAuthContext';
export { ThemeProvider, useTheme, GALLERY_THEME_PRESETS } from './ThemeContext';
export type { ThemeConfig, EventTheme } from './ThemeContext';
export { GALLERY_THEME_PRESETS as PRESET_THEMES } from './ThemeContext'; // For backward compatibility
export { MaintenanceProvider, useMaintenanceMode } from './MaintenanceContext';
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({
queryKey: ['image-security-settings'],
queryFn: async () => {
const response = await api.get('/api/admin/image-security/settings');
const response = await api.get('/admin/image-security/settings');
return response.data;
},
});
@@ -66,7 +66,7 @@ export const ImageSecurityTab: React.FC = () => {
// Save mutation
const saveMutation = useMutation({
mutationFn: async (newSettings: ImageSecuritySettings) => {
const response = await api.put('/api/admin/image-security/settings', newSettings);
const response = await api.put('/admin/image-security/settings', newSettings);
return response.data;
},
onSuccess: () => {
+2 -1
View File
@@ -1,4 +1,5 @@
export * from './useSessionTimeout';
export * from './useOnClickOutside';
export * from './useLocalizedDate';
export * from './useLocalizedTimeAgo';
export * from './useLocalizedTimeAgo';
export * from './usePermission';
+23
View File
@@ -0,0 +1,23 @@
import { usePermissions } from '../contexts/PermissionsContext';
/**
* Hook to check if the current user has a specific permission.
*
* @param permission - The permission to check
* @returns boolean indicating if the user has the permission
*/
export function usePermission(permission: string): boolean {
const { hasPermission } = usePermissions();
return hasPermission(permission);
}
/**
* Hook to check if the current user has any of the specified permissions.
*
* @param permissions - Array of permissions to check
* @returns boolean indicating if the user has any of the permissions
*/
export function useAnyPermission(permissions: string[]): boolean {
const { hasAnyPermission } = usePermissions();
return hasAnyPermission(permissions);
}
+162 -1
View File
@@ -1,4 +1,78 @@
{
"userManagement": {
"title": "Benutzerverwaltung",
"subtitle": "Admin-Benutzer und Einladungen verwalten",
"loading": "Lade Benutzer...",
"loadError": "Fehler beim Laden der Benutzer. Bitte versuchen Sie es erneut.",
"inviteUser": "Benutzer einladen",
"createInvitation": "Einladung erstellen",
"email": "E-Mail-Adresse",
"emailPlaceholder": "benutzer@beispiel.de",
"role": "Rolle",
"selectRole": "Rolle auswählen",
"sendInvitation": "Einladung senden",
"editUser": "Benutzer bearbeiten",
"editingUser": "Bearbeite Benutzer",
"saveChanges": "Änderungen speichern",
"deactivateUser": "Benutzer deaktivieren",
"cancelInvitation": "Einladung abbrechen",
"invitationSent": "Einladung erfolgreich gesendet",
"invitationError": "Fehler beim Senden der Einladung",
"invitationCancelled": "Einladung abgebrochen",
"cancelInvitationError": "Fehler beim Abbrechen der Einladung",
"userUpdated": "Benutzer erfolgreich aktualisiert",
"updateUserError": "Fehler beim Aktualisieren des Benutzers",
"userDeactivated": "Benutzer erfolgreich deaktiviert",
"deactivateUserError": "Fehler beim Deaktivieren des Benutzers",
"noRole": "Keine Rolle",
"neverLoggedIn": "Nie angemeldet",
"expired": "Abgelaufen",
"deactivate": "Deaktivieren",
"cancel": "Abbrechen",
"tabs": {
"users": "Benutzer",
"invitations": "Einladungen"
},
"stats": {
"totalUsers": "Benutzer gesamt",
"activeUsers": "Aktive Benutzer",
"pendingInvitations": "Ausstehende Einladungen",
"inactiveUsers": "Inaktive Benutzer"
},
"status": {
"active": "Aktiv",
"inactive": "Inaktiv"
},
"table": {
"user": "Benutzer",
"email": "E-Mail",
"role": "Rolle",
"status": "Status",
"lastLogin": "Letzte Anmeldung",
"actions": "Aktionen",
"invitedBy": "Eingeladen von",
"expires": "Läuft ab"
},
"validation": {
"emailRequired": "E-Mail ist erforderlich",
"emailInvalid": "Ungültiges E-Mail-Format",
"roleRequired": "Rolle ist erforderlich"
},
"searchUsersPlaceholder": "Benutzer suchen...",
"searchInvitationsPlaceholder": "Einladungen suchen...",
"noUsers": "Keine Benutzer gefunden",
"noUsersFound": "Keine Benutzer entsprechen Ihrer Suche",
"noInvitations": "Keine ausstehenden Einladungen",
"noInvitationsFound": "Keine Einladungen entsprechen Ihrer Suche",
"confirmDeactivate": {
"title": "Benutzer deaktivieren",
"message": "Sind Sie sicher, dass Sie {{name}} deaktivieren möchten? Sie können sich dann nicht mehr anmelden."
},
"confirmCancelInvitation": {
"title": "Einladung abbrechen",
"message": "Sind Sie sicher, dass Sie die Einladung für {{email}} abbrechen möchten?"
}
},
"common": {
"loading": "Wird geladen...",
"error": "Fehler",
@@ -84,7 +158,8 @@
"analytics": "Analytik",
"emailSettings": "E-Mail-Einstellungen",
"backup": "Backup & Wiederherstellung",
"cmsPages": "CMS-Seiten"
"cmsPages": "CMS-Seiten",
"users": "Benutzer"
},
"backup": {
"external": {
@@ -153,6 +228,10 @@
"title": "Backup nicht konfiguriert",
"message": "Bitte konfigurieren Sie die Backup-Einstellungen im Tab \"Konfiguration\", bevor Sie Backups ausführen."
},
"actions": {
"runBackupNow": "Backup jetzt starten",
"running": "Läuft..."
},
"coverage": {
"title": "Backup-Abdeckung",
"database": "Datenbank",
@@ -1282,6 +1361,88 @@
"admin_logout": "Admin {{actorName}} abgemeldet",
"system_activity": "Systemaktivität: {{type}}",
"unknown": "Unbekannte Aktivität"
},
"userManagement": "Benutzerverwaltung",
"inviteUser": "Benutzer einladen",
"pendingInvitations": "Ausstehende Einladungen",
"roles": {
"super_admin": "Super-Admin",
"admin": "Admin",
"editor": "Redakteur",
"viewer": "Betrachter"
},
"userStatus": {
"active": "Aktiv",
"inactive": "Inaktiv"
},
"inviteForm": {
"email": "E-Mail-Adresse",
"role": "Rolle",
"send": "Einladung senden"
},
"acceptInvite": {
"title": "Admin-Einladung annehmen",
"username": "Benutzernamen wählen",
"password": "Passwort erstellen",
"submit": "Konto erstellen"
}
},
"permissions": {
"insufficient": "Sie haben keine Berechtigung, diese Aktion auszuführen",
"viewOnly": "Nur Ansicht"
},
"acceptInvitation": {
"title": "Einladung annehmen",
"subtitle": "Erstellen Sie Ihr Administratorkonto",
"validating": "Einladung wird überprüft...",
"invalidToken": "Ungültige Einladung",
"invalidTokenMessage": "Dieser Einladungslink ist ungültig oder abgelaufen. Bitte kontaktieren Sie Ihren Administrator für eine neue Einladung.",
"expiredToken": "Einladung abgelaufen",
"expiredTokenMessage": "Diese Einladung ist abgelaufen. Bitte fordern Sie eine neue Einladung von Ihrem Administrator an.",
"alreadyUsed": "Einladung bereits verwendet",
"alreadyUsedMessage": "Diese Einladung wurde bereits verwendet, um ein Konto zu erstellen.",
"invitedAs": "Sie wurden eingeladen als",
"expiresAt": "Einladung läuft ab",
"usernameLabel": "Benutzername",
"usernamePlaceholder": "Wählen Sie einen Benutzernamen",
"usernameHelp": "3-50 Zeichen, nur Buchstaben, Zahlen, Unterstriche und Bindestriche",
"passwordLabel": "Passwort",
"passwordPlaceholder": "Erstellen Sie ein sicheres Passwort",
"confirmPasswordLabel": "Passwort bestätigen",
"confirmPasswordPlaceholder": "Bestätigen Sie Ihr Passwort",
"passwordStrength": "Passwortstärke",
"requirements": {
"title": "Passwortanforderungen:",
"minLength": "Mindestens 12 Zeichen",
"uppercase": "Mindestens ein Großbuchstabe",
"lowercase": "Mindestens ein Kleinbuchstabe",
"number": "Mindestens eine Zahl",
"special": "Mindestens ein Sonderzeichen"
},
"strength": {
"weak": "Schwach",
"fair": "Mittel",
"good": "Gut",
"strong": "Stark"
},
"createAccount": "Konto erstellen",
"creating": "Konto wird erstellt...",
"success": "Konto erstellt!",
"successMessage": "Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt mit Ihren Zugangsdaten anmelden.",
"redirecting": "Weiterleitung zur Anmeldung in {{seconds}}...",
"goToLogin": "Zur Anmeldung",
"errors": {
"usernameRequired": "Benutzername ist erforderlich",
"usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein",
"usernameTooLong": "Benutzername darf maximal 50 Zeichen lang sein",
"usernameInvalid": "Benutzername darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche enthalten",
"passwordRequired": "Passwort ist erforderlich",
"passwordTooShort": "Passwort muss mindestens 12 Zeichen lang sein",
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
"confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort",
"usernameTaken": "Dieser Benutzername ist bereits vergeben",
"emailTaken": "Ein Konto mit dieser E-Mail-Adresse existiert bereits",
"genericError": "Konto konnte nicht erstellt werden. Bitte versuchen Sie es erneut."
}
},
"errors": {
+257 -1
View File
@@ -1,4 +1,78 @@
{
"userManagement": {
"title": "User Management",
"subtitle": "Manage admin users and invitations",
"loading": "Loading users...",
"loadError": "Failed to load users. Please try again.",
"inviteUser": "Invite User",
"createInvitation": "Create Invitation",
"email": "Email Address",
"emailPlaceholder": "user@example.com",
"role": "Role",
"selectRole": "Select a role",
"sendInvitation": "Send Invitation",
"editUser": "Edit User",
"editingUser": "Editing user",
"saveChanges": "Save Changes",
"deactivateUser": "Deactivate User",
"cancelInvitation": "Cancel Invitation",
"invitationSent": "Invitation sent successfully",
"invitationError": "Failed to send invitation",
"invitationCancelled": "Invitation cancelled",
"cancelInvitationError": "Failed to cancel invitation",
"userUpdated": "User updated successfully",
"updateUserError": "Failed to update user",
"userDeactivated": "User deactivated successfully",
"deactivateUserError": "Failed to deactivate user",
"noRole": "No Role",
"neverLoggedIn": "Never logged in",
"expired": "Expired",
"deactivate": "Deactivate",
"cancel": "Cancel",
"tabs": {
"users": "Users",
"invitations": "Invitations"
},
"stats": {
"totalUsers": "Total Users",
"activeUsers": "Active Users",
"pendingInvitations": "Pending Invitations",
"inactiveUsers": "Inactive Users"
},
"status": {
"active": "Active",
"inactive": "Inactive"
},
"table": {
"user": "User",
"email": "Email",
"role": "Role",
"status": "Status",
"lastLogin": "Last Login",
"actions": "Actions",
"invitedBy": "Invited By",
"expires": "Expires"
},
"validation": {
"emailRequired": "Email is required",
"emailInvalid": "Invalid email format",
"roleRequired": "Role is required"
},
"searchUsersPlaceholder": "Search users...",
"searchInvitationsPlaceholder": "Search invitations...",
"noUsers": "No users found",
"noUsersFound": "No users match your search",
"noInvitations": "No pending invitations",
"noInvitationsFound": "No invitations match your search",
"confirmDeactivate": {
"title": "Deactivate User",
"message": "Are you sure you want to deactivate {{name}}? They will no longer be able to log in."
},
"confirmCancelInvitation": {
"title": "Cancel Invitation",
"message": "Are you sure you want to cancel the invitation for {{email}}?"
}
},
"common": {
"loading": "Loading...",
"error": "Error",
@@ -84,7 +158,8 @@
"analytics": "Analytics",
"emailSettings": "Email Settings",
"backup": "Backup & Restore",
"cmsPages": "CMS Pages"
"cmsPages": "CMS Pages",
"users": "Users"
},
"archives": {
"title": "Archives",
@@ -662,6 +737,80 @@
"failed": "Failed",
"lastUpdate": "Last update"
},
"events": {
"title": "Event Creation",
"requiredFields": "Required Fields",
"requiredFieldsDescription": "Configure which contact fields are required when creating new events.",
"requireCustomerName": "Require customer name",
"requireCustomerNameHelp": "Customer name must be provided for new events",
"requireCustomerEmail": "Require customer email",
"requireCustomerEmailHelp": "Customer email must be provided for new events",
"customerEmailWarning": "Required for sending gallery invitations",
"requireAdminEmail": "Require admin email",
"requireAdminEmailHelp": "Admin email must be provided for new events",
"adminEmailWarning": "Required for receiving event notifications",
"saveSettings": "Save Event Settings",
"noteTitle": "Note",
"noteText": "These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields."
},
"imageSecurity": {
"title": "Image Protection",
"saveSuccess": "Image security settings saved",
"saveError": "Failed to save settings",
"loadError": "Failed to load image security settings",
"defaultProtection": "Default Protection Settings",
"defaultProtectionHelp": "These settings apply to all new events. Individual events can override these defaults.",
"protectionLevel": "Default Protection Level",
"imageQuality": "Default Image Quality",
"fragmentationLevel": "Fragmentation Level",
"enableDevtools": "Enable DevTools detection by default",
"enableCanvas": "Enable canvas rendering by default (advanced protection)",
"rateLimiting": "Rate Limiting",
"rateLimitingHelp": "Limit how many images can be requested to prevent scraping.",
"requestsPerMinute": "Requests per minute",
"requestsPer5Minutes": "Requests per 5 min",
"requestsPerHour": "Requests per hour",
"securityMonitoring": "Security Monitoring",
"suspiciousThreshold": "Suspicious activity threshold",
"autoBlockThreshold": "Auto-block threshold",
"enableMonitoring": "Enable security monitoring",
"blockSuspiciousIps": "Automatically block suspicious IPs",
"logEvents": "Log security events to database",
"infoTitle": "About Image Protection",
"infoText": "These protection features help prevent casual downloading and copying but cannot block all methods. Determined users may still find ways to capture images. Consider using watermarks and legal agreements for comprehensive protection."
},
"moderation": {
"title": "Moderation",
"wordFilters": "Word Filters",
"description": "Manage words that should be filtered or blocked in comments",
"addFilter": "Add New Filter",
"enterWord": "Enter word to filter",
"searchFilters": "Search filters...",
"filterAdded": "Word filter added successfully",
"filterExists": "This word filter already exists",
"addError": "Failed to add word filter",
"filterUpdated": "Word filter updated successfully",
"updateError": "Failed to update word filter",
"filterDeleted": "Word filter deleted successfully",
"deleteError": "Failed to delete word filter",
"wordRequired": "Please enter a word to filter",
"confirmDelete": "Are you sure you want to delete this word filter?",
"loading": "Loading word filters...",
"noMatchingFilters": "No matching filters found",
"noFilters": "No word filters configured yet",
"severityLow": "Low",
"severityModerate": "Moderate",
"severityHigh": "High",
"severityBlock": "Block",
"severityLevels": "Severity Levels",
"lowDescription": "Word is flagged for review but not automatically blocked",
"moderateDescription": "Comment requires manual approval before being visible",
"highDescription": "Comment is automatically hidden and requires admin review",
"blockDescription": "Comment is rejected immediately and cannot be submitted"
},
"styling": {
"title": "Custom CSS"
},
"analytics": {
"title": "Analytics",
"umamiIntegration": "Umami Analytics Integration",
@@ -1015,6 +1164,88 @@
"admin_logout": "Admin {{actorName}} logged out",
"system_activity": "System activity: {{type}}",
"unknown": "Unknown activity"
},
"userManagement": "User Management",
"inviteUser": "Invite User",
"pendingInvitations": "Pending Invitations",
"roles": {
"super_admin": "Super Admin",
"admin": "Admin",
"editor": "Editor",
"viewer": "Viewer"
},
"userStatus": {
"active": "Active",
"inactive": "Inactive"
},
"inviteForm": {
"email": "Email Address",
"role": "Role",
"send": "Send Invitation"
},
"acceptInvite": {
"title": "Accept Admin Invitation",
"username": "Choose a Username",
"password": "Create Password",
"submit": "Create Account"
}
},
"permissions": {
"insufficient": "You don't have permission to perform this action",
"viewOnly": "View Only"
},
"acceptInvitation": {
"title": "Accept Invitation",
"subtitle": "Create your admin account",
"validating": "Validating invitation...",
"invalidToken": "Invalid Invitation",
"invalidTokenMessage": "This invitation link is invalid or has expired. Please contact your administrator for a new invitation.",
"expiredToken": "Invitation Expired",
"expiredTokenMessage": "This invitation has expired. Please request a new invitation from your administrator.",
"alreadyUsed": "Invitation Already Used",
"alreadyUsedMessage": "This invitation has already been used to create an account.",
"invitedAs": "You've been invited as",
"expiresAt": "Invitation expires",
"usernameLabel": "Username",
"usernamePlaceholder": "Choose a username",
"usernameHelp": "3-50 characters, letters, numbers, underscores, and hyphens only",
"passwordLabel": "Password",
"passwordPlaceholder": "Create a strong password",
"confirmPasswordLabel": "Confirm Password",
"confirmPasswordPlaceholder": "Confirm your password",
"passwordStrength": "Password strength",
"requirements": {
"title": "Password requirements:",
"minLength": "At least 12 characters",
"uppercase": "At least one uppercase letter",
"lowercase": "At least one lowercase letter",
"number": "At least one number",
"special": "At least one special character"
},
"strength": {
"weak": "Weak",
"fair": "Fair",
"good": "Good",
"strong": "Strong"
},
"createAccount": "Create Account",
"creating": "Creating account...",
"success": "Account Created!",
"successMessage": "Your account has been created successfully. You can now log in with your credentials.",
"redirecting": "Redirecting to login in {{seconds}}...",
"goToLogin": "Go to Login",
"errors": {
"usernameRequired": "Username is required",
"usernameTooShort": "Username must be at least 3 characters",
"usernameTooLong": "Username must be at most 50 characters",
"usernameInvalid": "Username can only contain letters, numbers, underscores, and hyphens",
"passwordRequired": "Password is required",
"passwordTooShort": "Password must be at least 12 characters",
"passwordsDoNotMatch": "Passwords do not match",
"confirmPasswordRequired": "Please confirm your password",
"usernameTaken": "This username is already taken",
"emailTaken": "An account with this email already exists",
"genericError": "Failed to create account. Please try again."
}
},
"errors": {
@@ -1233,6 +1464,10 @@
"title": "Backup Not Configured",
"message": "Please configure backup settings in the Configuration tab before running backups."
},
"actions": {
"runBackupNow": "Run Backup Now",
"running": "Running..."
},
"coverage": {
"title": "Backup Coverage",
"database": "Database",
@@ -1570,6 +1805,27 @@
"testEmailFailed": "Connection test failed"
}
},
"cssTemplates": {
"title": "Custom CSS Templates",
"template": "Template",
"templateName": "Template Name",
"enableTemplate": "Enable this template",
"enableHint": "Enabled templates can be selected when creating events",
"cssContent": "CSS Content",
"cssHint": "Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent",
"securityNotice": "Security Notice",
"securityText": "CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.",
"resetToDefault": "Reset to Default",
"resetConfirm": "Reset this template to the default? Your changes will be lost.",
"unsavedChanges": "Unsaved changes",
"saveTemplate": "Save Template",
"lastUpdated": "Last updated",
"saved": "Template saved successfully",
"saveFailed": "Failed to save template",
"sanitizationWarning": "Some CSS patterns were blocked for security",
"reset": "Template reset to default",
"resetFailed": "Failed to reset template"
},
"maintenance": {
"title": "System Maintenance",
"message": "We're currently performing scheduled maintenance to improve our service. We'll be back online shortly.",
+9 -8
View File
@@ -81,9 +81,8 @@ export const BrandingPage: React.FC = () => {
useEffect(() => {
if (settings) {
const formatted = settingsService.formatBrandingSettings(settings);
// Don't set logo_url here - it will be synced from theme
const { logo_url, ...brandingWithoutLogo } = formatted;
setBrandingSettings(prev => ({ ...prev, ...brandingWithoutLogo }));
// Include logo_url from branding settings
setBrandingSettings(prev => ({ ...prev, ...formatted }));
}
}, [settings]);
@@ -91,15 +90,17 @@ export const BrandingPage: React.FC = () => {
useEffect(() => {
if (themeSettings) {
const formatted = settingsService.formatThemeSettings(themeSettings) as ThemeConfig;
if (formatted && Object.keys(formatted).length > 0) {
// Use the theme's logo URL as stored in the theme config
setCurrentTheme(formatted);
setTheme(formatted);
// Always sync the logo URL from theme to branding settings - theme is source of truth
setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl || '' }));
// Only sync logo URL from theme if it exists there (logo is stored in branding settings)
if (formatted.logoUrl) {
setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl }));
}
// Try to identify which preset this matches
for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) {
if (JSON.stringify(preset.config) === JSON.stringify(formatted)) {
+19 -1
View File
@@ -58,6 +58,7 @@ import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams, type FeedbackFilters, type FilterSummary } from '../../services/photos.service';
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.service';
import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
const resolveShareLink = (link: string): string => {
@@ -141,6 +142,7 @@ export const EventDetailsPage: React.FC = () => {
type EditFormState = {
welcome_message: string;
color_theme: string;
css_template_id: number | null;
expires_at: string;
allow_user_uploads: boolean;
upload_category_id: number | null;
@@ -164,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
const [editForm, setEditForm] = useState<EditFormState>({
welcome_message: '',
color_theme: '',
css_template_id: null,
expires_at: '',
allow_user_uploads: false,
upload_category_id: null,
@@ -207,7 +210,17 @@ export const EventDetailsPage: React.FC = () => {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
// Fetch CSS templates when component mounts or editing starts
useEffect(() => {
if (isEditing) {
cssTemplatesService.getEnabledTemplates()
.then(setCssTemplates)
.catch(err => console.error('Failed to load CSS templates:', err));
}
}, [isEditing]);
// Photo filters state
const [photoFilters, setPhotoFilters] = useState<PhotoFilterParams>({
category_id: undefined as number | null | undefined,
@@ -375,6 +388,7 @@ export const EventDetailsPage: React.FC = () => {
setEditForm({
welcome_message: event.welcome_message || '',
color_theme: event.color_theme || '',
css_template_id: event.css_template_id || null,
expires_at: format(safeParseDate(event.expires_at), 'yyyy-MM-dd'),
allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null,
@@ -474,6 +488,7 @@ export const EventDetailsPage: React.FC = () => {
expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
css_template_id: editForm.css_template_id,
// Download protection settings
protection_level: editForm.protection_level,
disable_right_click: editForm.disable_right_click,
@@ -1357,6 +1372,9 @@ export const EventDetailsPage: React.FC = () => {
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
cssTemplates={cssTemplates}
cssTemplateId={editForm.css_template_id}
onCssTemplateChange={(templateId) => setEditForm(prev => ({ ...prev, css_template_id: templateId }))}
/>
</Card>
)}
@@ -0,0 +1,973 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import {
Users,
Mail,
Plus,
Search,
Edit,
UserX,
X,
AlertTriangle,
Clock,
Shield,
Trash2,
CheckCircle,
XCircle,
} from 'lucide-react';
import { parseISO, formatDistanceToNow, isPast } from 'date-fns';
import { Button, Input, Card, Loading } from '../../components/common';
import { userManagementService } from '../../services/userManagement.service';
import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
type TabType = 'users' | 'invitations';
// Role badge colors
const getRoleBadgeColor = (roleName: string): string => {
switch (roleName?.toLowerCase()) {
case 'super_admin':
return 'bg-red-100 text-red-700 border-red-200';
case 'admin':
return 'bg-blue-100 text-blue-700 border-blue-200';
case 'editor':
return 'bg-green-100 text-green-700 border-green-200';
case 'viewer':
default:
return 'bg-neutral-100 text-neutral-700 border-neutral-200';
}
};
// Modal component for creating invitations
interface CreateInvitationModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (email: string, roleId: number) => void;
roles: AdminRole[];
isLoading: boolean;
}
const CreateInvitationModal: React.FC<CreateInvitationModalProps> = ({
isOpen,
onClose,
onSubmit,
roles,
isLoading,
}) => {
const { t } = useTranslation();
const [email, setEmail] = useState('');
const [roleId, setRoleId] = useState<number | ''>('');
const [errors, setErrors] = useState<{ email?: string; role?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: { email?: string; role?: string } = {};
if (!email) {
newErrors.email = t('userManagement.validation.emailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t('userManagement.validation.emailInvalid');
}
if (!roleId) {
newErrors.role = t('userManagement.validation.roleRequired');
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(email, roleId as number);
};
const handleClose = () => {
setEmail('');
setRoleId('');
setErrors({});
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('userManagement.createInvitation')}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.email')}
</label>
<Input
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setErrors((prev) => ({ ...prev, email: undefined }));
}}
placeholder={t('userManagement.emailPlaceholder')}
disabled={isLoading}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.role')}
</label>
<select
value={roleId}
onChange={(e) => {
setRoleId(e.target.value ? Number(e.target.value) : '');
setErrors((prev) => ({ ...prev, role: undefined }));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
<option value="">{t('userManagement.selectRole')}</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.displayName}
</option>
))}
</select>
{errors.role && (
<p className="mt-1 text-sm text-red-600">{errors.role}</p>
)}
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isLoading}
leftIcon={<Mail className="w-4 h-4" />}
>
{t('userManagement.sendInvitation')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
// Modal component for editing users
interface EditUserModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (userId: number, roleId: number) => void;
user: AdminUser | null;
roles: AdminRole[];
isLoading: boolean;
}
const EditUserModal: React.FC<EditUserModalProps> = ({
isOpen,
onClose,
onSubmit,
user,
roles,
isLoading,
}) => {
const { t } = useTranslation();
const [roleId, setRoleId] = useState<number | ''>('');
React.useEffect(() => {
if (user?.roleId) {
setRoleId(user.roleId);
}
}, [user]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!user || !roleId) return;
onSubmit(user.id, roleId as number);
};
const handleClose = () => {
setRoleId('');
onClose();
};
if (!isOpen || !user) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('userManagement.editUser')}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<div className="mb-4 p-3 bg-neutral-50 rounded-lg">
<p className="text-sm text-neutral-600">
{t('userManagement.editingUser')}: <strong>{user.username}</strong>
</p>
<p className="text-sm text-neutral-500">{user.email}</p>
</div>
<form onSubmit={handleSubmit}>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('userManagement.role')}
</label>
<select
value={roleId}
onChange={(e) => setRoleId(e.target.value ? Number(e.target.value) : '')}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
disabled={isLoading}
>
<option value="">{t('userManagement.selectRole')}</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.displayName}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={isLoading}
leftIcon={<Edit className="w-4 h-4" />}
>
{t('userManagement.saveChanges')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
// Confirmation dialog component
interface ConfirmDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText: string;
isLoading: boolean;
variant?: 'danger' | 'warning';
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText,
isLoading,
variant = 'danger',
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-start gap-3 mb-4">
<div
className={`p-2 rounded-full ${
variant === 'danger' ? 'bg-red-100' : 'bg-amber-100'
}`}
>
<AlertTriangle
className={`w-5 h-5 ${
variant === 'danger' ? 'text-red-600' : 'text-amber-600'
}`}
/>
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900">{title}</h2>
<p className="text-sm text-neutral-600 mt-1">{message}</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={onConfirm}
isLoading={isLoading}
className={
variant === 'danger'
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: ''
}
>
{confirmText}
</Button>
</div>
</div>
</Card>
</div>
);
};
export const UserManagementPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// State
const [activeTab, setActiveTab] = useState<TabType>('users');
const [searchTerm, setSearchTerm] = useState('');
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false);
const [showEditUserModal, setShowEditUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
type: 'deactivate' | 'cancelInvitation';
id: number;
name: string;
} | null>(null);
// Queries
const {
data: users,
isLoading: usersLoading,
error: usersError,
} = useQuery({
queryKey: ['admin-users'],
queryFn: userManagementService.getUsers,
});
const {
data: roles,
isLoading: rolesLoading,
} = useQuery({
queryKey: ['admin-roles'],
queryFn: userManagementService.getRoles,
});
const {
data: invitations,
isLoading: invitationsLoading,
error: invitationsError,
} = useQuery({
queryKey: ['admin-invitations'],
queryFn: userManagementService.getInvitations,
});
// Mutations
const createInvitationMutation = useMutation({
mutationFn: ({ email, roleId }: { email: string; roleId: number }) =>
userManagementService.createInvitation({ email, role_id: roleId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
setShowCreateInvitationModal(false);
toast.success(t('userManagement.invitationSent'));
},
onError: (error: Error) => {
toast.error(error.message || t('userManagement.invitationError'));
},
});
const cancelInvitationMutation = useMutation({
mutationFn: userManagementService.cancelInvitation,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
setConfirmDialog(null);
toast.success(t('userManagement.invitationCancelled'));
},
onError: () => {
toast.error(t('userManagement.cancelInvitationError'));
},
});
const updateUserMutation = useMutation({
mutationFn: ({ id, roleId }: { id: number; roleId: number }) =>
userManagementService.updateUser(id, { roleId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setShowEditUserModal(false);
setSelectedUser(null);
toast.success(t('userManagement.userUpdated'));
},
onError: () => {
toast.error(t('userManagement.updateUserError'));
},
});
const deactivateUserMutation = useMutation({
mutationFn: userManagementService.deactivateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null);
toast.success(t('userManagement.userDeactivated'));
},
onError: () => {
toast.error(t('userManagement.deactivateUserError'));
},
});
// Filtered data
const filteredUsers = useMemo(() => {
if (!users) return [];
if (!searchTerm) return users;
const term = searchTerm.toLowerCase();
return users.filter(
(user) =>
user.username.toLowerCase().includes(term) ||
user.email.toLowerCase().includes(term) ||
user.roleName?.toLowerCase().includes(term)
);
}, [users, searchTerm]);
const filteredInvitations = useMemo(() => {
if (!invitations) return [];
if (!searchTerm) return invitations;
const term = searchTerm.toLowerCase();
return invitations.filter(
(invitation) =>
invitation.email.toLowerCase().includes(term) ||
invitation.roleName?.toLowerCase().includes(term)
);
}, [invitations, searchTerm]);
// Handlers
const handleCreateInvitation = (email: string, roleId: number) => {
createInvitationMutation.mutate({ email, roleId });
};
const handleEditUser = (user: AdminUser) => {
setSelectedUser(user);
setShowEditUserModal(true);
};
const handleUpdateUser = (userId: number, roleId: number) => {
updateUserMutation.mutate({ id: userId, roleId });
};
const handleDeactivateUser = (user: AdminUser) => {
setConfirmDialog({
isOpen: true,
type: 'deactivate',
id: user.id,
name: user.username,
});
};
const handleCancelInvitation = (invitation: AdminInvitation) => {
setConfirmDialog({
isOpen: true,
type: 'cancelInvitation',
id: invitation.id,
name: invitation.email,
});
};
const handleConfirmAction = () => {
if (!confirmDialog) return;
if (confirmDialog.type === 'deactivate') {
deactivateUserMutation.mutate(confirmDialog.id);
} else if (confirmDialog.type === 'cancelInvitation') {
cancelInvitationMutation.mutate(confirmDialog.id);
}
};
// Loading state
const isLoading = usersLoading || rolesLoading || invitationsLoading;
if (isLoading) {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('userManagement.loading')} />
</div>
</div>
);
}
// Error state
if (usersError || invitationsError) {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<div className="text-center py-12">
<p className="text-red-600">{t('userManagement.loadError')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
{t('common.tryAgain')}
</Button>
</div>
</div>
);
}
const tabs: { key: TabType; label: string; count: number }[] = [
{ key: 'users', label: t('userManagement.tabs.users'), count: users?.length || 0 },
{
key: 'invitations',
label: t('userManagement.tabs.invitations'),
count: invitations?.length || 0,
},
];
return (
<div>
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">
{t('userManagement.title')}
</h1>
<p className="text-neutral-600 mt-1">{t('userManagement.subtitle')}</p>
</div>
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => setShowCreateInvitationModal(true)}
>
{t('userManagement.inviteUser')}
</Button>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.totalUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.length || 0}
</p>
</div>
<Users className="w-8 h-8 text-primary-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.activeUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.filter((u) => u.isActive).length || 0}
</p>
</div>
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.pendingInvitations')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{invitations?.length || 0}
</p>
</div>
<Mail className="w-8 h-8 text-blue-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">
{t('userManagement.stats.inactiveUsers')}
</p>
<p className="text-2xl font-bold text-neutral-900">
{users?.filter((u) => !u.isActive).length || 0}
</p>
</div>
<XCircle className="w-8 h-8 text-neutral-400" />
</div>
</Card>
</div>
{/* Tab Navigation */}
<div className="border-b border-neutral-200 mb-6">
<nav className="-mb-px flex gap-6">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors flex items-center gap-2 ${
activeTab === tab.key
? 'border-primary-600 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{tab.label}
<span
className={`px-2 py-0.5 text-xs rounded-full ${
activeTab === tab.key
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-600'
}`}
>
{tab.count}
</span>
</button>
))}
</nav>
</div>
{/* Search */}
<Card padding="sm" className="mb-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<Input
type="text"
placeholder={
activeTab === 'users'
? t('userManagement.searchUsersPlaceholder')
: t('userManagement.searchInvitationsPlaceholder')
}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
</Card>
{/* Users Tab Content */}
{activeTab === 'users' && (
<Card className="overflow-visible">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.user')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.lastLogin')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
{searchTerm
? t('userManagement.noUsersFound')
: t('userManagement.noUsers')}
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
<span className="text-primary-700 font-medium text-sm">
{user.username.charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">
{user.username}
</p>
<p className="text-xs text-neutral-500">{user.email}</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(
user.roleName || ''
)}`}
>
<Shield className="w-3 h-3" />
{user.roleDisplayName || user.roleName || t('userManagement.noRole')}
</span>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.isActive
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-500'
}`}
>
{user.isActive
? t('userManagement.status.active')
: t('userManagement.status.inactive')}
</span>
</td>
<td className="px-6 py-4">
{user.lastLogin ? (
<div className="flex items-center gap-1 text-sm text-neutral-600">
<Clock className="w-4 h-4" />
{formatDistanceToNow(parseISO(user.lastLogin), {
addSuffix: true,
})}
</div>
) : (
<span className="text-sm text-neutral-400">
{t('userManagement.neverLoggedIn')}
</span>
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEditUser(user)}
className="p-1.5 text-neutral-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
title={t('userManagement.editUser')}
>
<Edit className="w-4 h-4" />
</button>
{user.isActive && (
<button
onClick={() => handleDeactivateUser(user)}
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title={t('userManagement.deactivateUser')}
>
<UserX className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Invitations Tab Content */}
{activeTab === 'invitations' && (
<Card className="overflow-visible">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.email')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.role')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.invitedBy')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.expires')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('userManagement.table.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredInvitations.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-neutral-500">
{searchTerm
? t('userManagement.noInvitationsFound')
: t('userManagement.noInvitations')}
</td>
</tr>
) : (
filteredInvitations.map((invitation) => {
const isExpired = isPast(parseISO(invitation.expiresAt));
return (
<tr key={invitation.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Mail className="w-5 h-5 text-blue-600" />
</div>
<p className="text-sm font-medium text-neutral-900">
{invitation.email}
</p>
</div>
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${getRoleBadgeColor(
invitation.roleName || ''
)}`}
>
<Shield className="w-3 h-3" />
{invitation.roleName}
</span>
</td>
<td className="px-6 py-4 text-sm text-neutral-600">
{invitation.invitedBy || '-'}
</td>
<td className="px-6 py-4">
<span
className={`inline-flex items-center gap-1 text-sm ${
isExpired ? 'text-red-600' : 'text-neutral-600'
}`}
>
<Clock className="w-4 h-4" />
{isExpired
? t('userManagement.expired')
: formatDistanceToNow(parseISO(invitation.expiresAt), {
addSuffix: true,
})}
</span>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={() => handleCancelInvitation(invitation)}
className="p-1.5 text-neutral-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title={t('userManagement.cancelInvitation')}
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Card>
)}
{/* Create Invitation Modal */}
<CreateInvitationModal
isOpen={showCreateInvitationModal}
onClose={() => setShowCreateInvitationModal(false)}
onSubmit={handleCreateInvitation}
roles={roles || []}
isLoading={createInvitationMutation.isPending}
/>
{/* Edit User Modal */}
<EditUserModal
isOpen={showEditUserModal}
onClose={() => {
setShowEditUserModal(false);
setSelectedUser(null);
}}
onSubmit={handleUpdateUser}
user={selectedUser}
roles={roles || []}
isLoading={updateUserMutation.isPending}
/>
{/* Confirmation Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
onClose={() => setConfirmDialog(null)}
onConfirm={handleConfirmAction}
title={
confirmDialog.type === 'deactivate'
? t('userManagement.confirmDeactivate.title')
: t('userManagement.confirmCancelInvitation.title')
}
message={
confirmDialog.type === 'deactivate'
? t('userManagement.confirmDeactivate.message', { name: confirmDialog.name })
: t('userManagement.confirmCancelInvitation.message', {
email: confirmDialog.name,
})
}
confirmText={
confirmDialog.type === 'deactivate'
? t('userManagement.deactivate')
: t('userManagement.cancel')
}
isLoading={
confirmDialog.type === 'deactivate'
? deactivateUserMutation.isPending
: cancelInvitationMutation.isPending
}
variant={confirmDialog.type === 'deactivate' ? 'danger' : 'warning'}
/>
)}
</div>
);
};
UserManagementPage.displayName = 'UserManagementPage';
+2 -1
View File
@@ -10,4 +10,5 @@ export { BrandingPage } from './BrandingPage';
export { SettingsPage } from './SettingsPage';
export { CMSPage } from './CMSPage';
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';
+2 -1
View File
@@ -8,4 +8,5 @@ export { emailService } from './email.service';
export { settingsService } from './settings.service';
export { cmsService } from './cms.service';
export { notificationsService } from './notifications.service';
export { feedbackService } from './feedback.service';
export { feedbackService } from './feedback.service';
export { userManagementService } from './userManagement.service';
+3 -2
View File
@@ -66,8 +66,9 @@ class PhotosService {
await api.post(`/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
}
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> {
await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | string | null): Promise<AdminPhoto> {
const response = await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
return response.data.photo;
}
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
@@ -0,0 +1,187 @@
import { api } from '../config/api';
import type { AdminUser, AdminRole, AdminInvitation } from '../types';
// Transform snake_case API response to camelCase for frontend
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformUser(user: any): AdminUser {
return {
id: user.id,
username: user.username,
email: user.email,
isActive: user.isActive ?? user.is_active,
lastLogin: user.lastLogin ?? user.last_login,
lastLoginIp: user.lastLoginIp ?? user.last_login_ip,
createdAt: user.createdAt ?? user.created_at,
updatedAt: user.updatedAt ?? user.updated_at,
roleId: user.roleId ?? user.role_id,
roleName: user.roleName ?? user.role_name,
roleDisplayName: user.roleDisplayName ?? user.role_display_name,
createdByUsername: user.createdByUsername ?? user.created_by_username,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformRole(role: any): AdminRole {
return {
id: role.id,
name: role.name,
displayName: role.displayName ?? role.display_name,
description: role.description,
isSystem: role.isSystem ?? role.is_system,
priority: role.priority,
};
}
interface GetUsersResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
users: any[];
}
interface GetUserResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
user: any;
}
interface GetRolesResponse {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
roles: any[];
}
interface GetInvitationsResponse {
invitations: AdminInvitation[];
}
interface CreateInvitationData {
email: string;
role_id: number;
}
interface CreateInvitationResponse {
invitation: AdminInvitation;
message: string;
}
interface UpdateUserData {
roleId?: number;
isActive?: boolean;
}
interface UpdateUserResponse {
user: AdminUser;
}
interface DeactivateUserResponse {
message: string;
}
interface ResetPasswordResponse {
temporaryPassword: string;
message: string;
}
interface ValidateInvitationResponse {
valid: boolean;
email: string;
roleName: string;
invitedBy: string;
expiresAt: string;
}
interface AcceptInvitationData {
username: string;
password: string;
}
interface AcceptInvitationResponse {
message: string;
user: AdminUser;
}
export const userManagementService = {
/**
* Get all admin users
*/
async getUsers(): Promise<AdminUser[]> {
const response = await api.get<GetUsersResponse>('/admin/users');
return response.data.users.map(transformUser);
},
/**
* Get a single admin user by ID
*/
async getUser(id: number): Promise<AdminUser> {
const response = await api.get<GetUserResponse>(`/admin/users/${id}`);
return transformUser(response.data.user);
},
/**
* Get all available roles
*/
async getRoles(): Promise<AdminRole[]> {
const response = await api.get<GetRolesResponse>('/admin/users/roles');
return response.data.roles.map(transformRole);
},
/**
* Get all pending invitations
*/
async getInvitations(): Promise<AdminInvitation[]> {
const response = await api.get<GetInvitationsResponse>('/admin/users/invitations');
return response.data.invitations;
},
/**
* Create a new invitation
*/
async createInvitation(data: CreateInvitationData): Promise<AdminInvitation> {
const response = await api.post<CreateInvitationResponse>('/admin/users/invite', data);
return response.data.invitation;
},
/**
* Cancel a pending invitation
*/
async cancelInvitation(id: number): Promise<void> {
await api.delete(`/admin/users/invitations/${id}`);
},
/**
* Update an admin user
*/
async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> {
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, data);
return transformUser(response.data.user);
},
/**
* Deactivate an admin user
*/
async deactivateUser(id: number): Promise<string> {
const response = await api.post<DeactivateUserResponse>(`/admin/users/${id}/deactivate`);
return response.data.message;
},
/**
* Reset an admin user's password
*/
async resetPassword(id: number): Promise<ResetPasswordResponse> {
const response = await api.post<ResetPasswordResponse>(`/admin/users/${id}/reset-password`);
return response.data;
},
/**
* Validate an invitation token (public endpoint)
*/
async validateInvitation(token: string): Promise<ValidateInvitationResponse> {
const response = await api.get<ValidateInvitationResponse>(`/invite/${token}`);
return response.data;
},
/**
* Accept an invitation and create account (public endpoint)
*/
async acceptInvitation(token: string, data: AcceptInvitationData): Promise<AcceptInvitationResponse> {
const response = await api.post<AcceptInvitationResponse>(`/invite/${token}`, data);
return response.data;
},
};
+39
View File
@@ -144,6 +144,18 @@ export interface AdminUser {
username: string;
email: string;
mustChangePassword?: boolean;
role?: {
name: string;
displayName: string;
};
roleId?: number;
roleName?: string;
roleDisplayName?: string;
isActive?: boolean;
lastLogin?: string | null;
lastLoginIp?: string | null;
createdAt?: string;
createdByUsername?: string;
}
export interface LoginResponse {
@@ -178,5 +190,32 @@ export interface ApiError {
}>;
}
// Role and Permission types
export interface AdminRole {
id: number;
name: string;
displayName: string;
description?: string;
isSystem?: boolean;
priority?: number;
}
export interface AdminPermissions {
role: {
name: string;
displayName: string;
} | null;
permissions: string[];
}
export interface AdminInvitation {
id: number;
email: string;
roleName: string;
invitedBy: string;
expiresAt: string;
createdAt: string;
}
// Export protection types
export * from './protection';
+4
View File
@@ -36,6 +36,10 @@ const config: VitestUserConfig = {
target: 'http://localhost:7101',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:7101',
changeOrigin: true,
},
},
}
}