refactor: Phase 1 code consolidation and service layer setup

Phase 1.1: Shared parsers utility
- Create backend/src/utils/parsers.js with parseBooleanInput, parseStringInput, etc.
- Create frontend/src/utils/parsers.ts with TypeScript equivalents
- Update routes to import from shared parsers

Phase 1.2: Auth routes consolidation
- Merge auth.js, auth-enhanced.js, auth-enhanced-v2.js into single auth.js
- Add password change and password strength endpoints
- Consolidate middleware (auth.js with token revocation support)
- Update all imports across 14+ route files

Phase 1.3: CreateEvent page consolidation
- Remove duplicate CreateEventPage.tsx (basic version)
- Rename CreateEventPageEnhanced.tsx to CreateEventPage.tsx
- Update exports and imports

Phase 1.4: CMS page consolidation
- Remove duplicate CMSPage.tsx (basic version)
- Rename CMSPageEnhanced.tsx to CMSPage.tsx
- Update exports and imports

Phase 1.5: Multer config factory
- Create backend/src/config/multerConfig.js
- Centralized upload configuration with presets for photos, logos, favicons
- Reusable helpers: createDiskStorage, createFileFilter, uploadTimeoutMiddleware

Phase 2.1: Event service layer
- Create backend/src/services/eventService.js
- Move event business logic out of routes
- Functions: createEvent, getAllEvents, updateEvent, deleteEvent, extendExpiration
This commit is contained in:
Paul Nothaft
2026-01-02 10:12:24 +01:00
parent 77a4bfd499
commit 3424bd22ee
34 changed files with 2450 additions and 3309 deletions
+6 -23
View File
@@ -262,6 +262,7 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.940.0.tgz",
"integrity": "sha512-Wi4qnBT6shRRMXuuTgjMFTU5mu2KFWisgcigEMPptjPGUtJvBVi4PTGgS64qsLoUk/obqDAyOBOfEtRZ2ddC2w==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
@@ -1025,6 +1026,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -3831,6 +3833,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4431,6 +4434,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -5396,29 +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,
"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",
@@ -5546,6 +5527,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -6703,6 +6685,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
+1 -1
View File
@@ -34,7 +34,7 @@ const {
} = require('./src/utils/tokenUtils');
// Import routes
const authRoutes = require('./src/routes/auth-enhanced');
const authRoutes = require('./src/routes/auth');
const eventRoutes = require('./src/routes/events');
const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin');
+255
View File
@@ -0,0 +1,255 @@
/**
* Centralized Multer Configuration Factory
* Provides pre-configured multer instances for different upload scenarios
*
* @module config/multerConfig
*/
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { validateFileType } = require('../utils/fileSecurityUtils');
/**
* Get the storage path from environment or default
* @returns {string}
*/
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Default allowed MIME types for different upload types
*/
const ALLOWED_TYPES = {
photos: ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
videos: ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'],
media: ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'],
logos: ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'],
favicons: ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'],
documents: ['application/pdf', 'text/plain']
};
/**
* Default file size limits (in bytes)
*/
const SIZE_LIMITS = {
small: 1 * 1024 * 1024, // 1MB
medium: 5 * 1024 * 1024, // 5MB
large: 50 * 1024 * 1024, // 50MB
xlarge: 500 * 1024 * 1024, // 500MB
huge: 10 * 1024 * 1024 * 1024 // 10GB (for large videos)
};
/**
* Create a disk storage configuration
*
* @param {Object} options - Storage options
* @param {string} options.subdir - Subdirectory within storage path
* @param {Function} [options.filename] - Custom filename generator
* @param {boolean} [options.useTemp] - Use temp directory instead
* @returns {multer.StorageEngine}
*/
const createDiskStorage = (options = {}) => {
const { subdir, filename, useTemp = false } = options;
return multer.diskStorage({
destination: async (req, file, cb) => {
try {
let uploadDir;
if (useTemp) {
uploadDir = path.join(getStoragePath(), 'temp', `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`);
} else {
uploadDir = path.join(getStoragePath(), subdir || 'uploads');
}
// Create directory synchronously to prevent race conditions
fs.mkdirSync(uploadDir, { recursive: true });
cb(null, uploadDir);
} catch (error) {
cb(error);
}
},
filename: filename || ((req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
const ext = path.extname(file.originalname);
const baseName = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9-_]/g, '_');
cb(null, `${baseName}-${uniqueSuffix}${ext}`);
})
});
};
/**
* Create a file filter function
*
* @param {string[]} allowedTypes - Array of allowed MIME types
* @param {Object} [options] - Filter options
* @param {boolean} [options.validateMagicNumbers] - Whether to validate file magic numbers
* @param {string[]} [options.skipMagicValidation] - MIME types to skip magic number validation for
* @returns {Function} Multer file filter function
*/
const createFileFilter = (allowedTypes, options = {}) => {
const { validateMagicNumbers = true, skipMagicValidation = [] } = options;
return (req, file, cb) => {
// Basic MIME type check
if (!allowedTypes.includes(file.mimetype)) {
return cb(new Error(`File type ${file.mimetype} not allowed. Allowed types: ${allowedTypes.join(', ')}`));
}
// Validate file type with magic numbers (if enabled and not skipped)
if (validateMagicNumbers && !skipMagicValidation.includes(file.mimetype)) {
if (validateFileType && !validateFileType(file.originalname, file.mimetype, allowedTypes)) {
return cb(new Error('File content does not match file type'));
}
}
cb(null, true);
};
};
/**
* Create a multer instance for photo uploads
*
* @param {Object} [options] - Override options
* @returns {multer.Multer}
*/
const createPhotoUploader = (options = {}) => {
const defaults = {
storage: createDiskStorage({ useTemp: true }),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.huge,
files: options.maxFiles || 2000,
fieldSize: 10 * 1024 * 1024,
parts: 10000,
headerPairs: 2000
},
fileFilter: createFileFilter(ALLOWED_TYPES.media, {
validateMagicNumbers: true
})
};
return multer({ ...defaults, ...options });
};
/**
* Create a multer instance for logo uploads
*
* @param {Object} [options] - Override options
* @returns {multer.Multer}
*/
const createLogoUploader = (options = {}) => {
const defaults = {
storage: createDiskStorage({
subdir: 'uploads/logos',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `logo-${Date.now()}${ext}`);
}
}),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.medium
},
fileFilter: createFileFilter(ALLOWED_TYPES.logos, {
skipMagicValidation: ['image/svg+xml']
})
};
return multer({ ...defaults, ...options });
};
/**
* Create a multer instance for favicon uploads
*
* @param {Object} [options] - Override options
* @returns {multer.Multer}
*/
const createFaviconUploader = (options = {}) => {
const defaults = {
storage: createDiskStorage({
subdir: 'uploads/favicons',
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `favicon-${Date.now()}${ext}`);
}
}),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.small
},
fileFilter: createFileFilter(ALLOWED_TYPES.favicons, {
skipMagicValidation: ['image/x-icon', 'image/vnd.microsoft.icon']
})
};
return multer({ ...defaults, ...options });
};
/**
* Create a multer instance for gallery user uploads
*
* @param {string} destDir - Destination directory
* @param {Object} [options] - Override options
* @returns {multer.Multer}
*/
const createGalleryUploader = (destDir, options = {}) => {
const defaults = {
dest: destDir,
limits: {
fileSize: options.maxSize || SIZE_LIMITS.large,
files: options.maxFiles || 10
},
fileFilter: createFileFilter(ALLOWED_TYPES.photos)
};
return multer({ ...defaults, ...options });
};
/**
* Create a custom multer instance
*
* @param {Object} config - Full multer configuration
* @returns {multer.Multer}
*/
const createCustomUploader = (config) => {
return multer(config);
};
/**
* Upload timeout middleware
*
* @param {number} [timeout=300000] - Timeout in milliseconds (default 5 minutes)
* @returns {Function} Express middleware
*/
const uploadTimeoutMiddleware = (timeout = 300000) => {
return (req, res, next) => {
req.setTimeout(timeout, () => {
console.error('Upload request timed out');
if (!res.headersSent) {
res.status(408).json({ error: 'Upload request timed out' });
}
});
res.setTimeout(timeout, () => {
console.error('Upload response timed out');
});
next();
};
};
module.exports = {
// Pre-configured uploaders
createPhotoUploader,
createLogoUploader,
createFaviconUploader,
createGalleryUploader,
createCustomUploader,
// Building blocks for custom configurations
createDiskStorage,
createFileFilter,
// Middleware
uploadTimeoutMiddleware,
// Constants
ALLOWED_TYPES,
SIZE_LIMITS
};
-169
View File
@@ -1,169 +0,0 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
/**
* Enhanced admin authentication middleware with revocation checking
*/
async function adminAuth(req, res, next) {
try {
const token = getAdminTokenFromRequest(req);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
logger.warn('Revoked token used', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Verify token type
if (decoded.type !== 'admin') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
}
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
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({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
}
// Add user info to request
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email
};
req.token = token; // Store token for potential revocation
next();
} catch (error) {
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Enhanced gallery authentication middleware with revocation checking
*/
async function galleryAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid session' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
}
// Check if event still exists and is active
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Check if gallery has expired
if (new Date(event.expires_at) < new Date()) {
return res.status(410).json({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
}
// Add event info to request
req.event = event;
req.galleryToken = decoded;
req.token = token;
next();
} catch (error) {
logger.error('Gallery auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
// Export other middleware functions from original file...
module.exports = {
adminAuth,
galleryAuth,
// ... other exports
};
-241
View File
@@ -1,241 +0,0 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
/**
* Enhanced admin authentication middleware
* Adds additional security checks beyond basic JWT validation
*/
async function adminAuth(req, res, next) {
try {
const token = getAdminTokenFromRequest(req);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload; // Extract payload when using complete: true
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// Verify token type
if (decoded.type !== 'admin') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
// Optional: Reject if IP doesn't match
// return res.status(401).json({ error: 'Invalid token' });
}
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
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({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
}
// Add user info to request
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email
};
next();
} catch (error) {
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Enhanced gallery authentication middleware
*/
async function galleryAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid session' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
}
// Check if event still exists and is active
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Check if gallery has expired
if (new Date(event.expires_at) < new Date()) {
return res.status(410).json({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
}
// Add event info to request
req.event = event;
req.galleryToken = decoded;
next();
} catch (error) {
logger.error('Gallery auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Photo access authentication
* Validates both admin and gallery tokens for photo access
*/
async function photoAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
req.auth = { type: 'admin', user: admin };
} else if (decoded.type === 'gallery') {
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// For gallery tokens, ensure they can only access their event's photos
req.auth = { type: 'gallery', event: event };
} else {
return res.status(403).json({ error: 'Invalid token type' });
}
next();
} catch (error) {
logger.error('Photo auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Verify gallery access for specific operations
*/
async function verifyGalleryAccess(req, res, next) {
try {
if (!req.auth) {
return res.status(401).json({ error: 'Authentication required' });
}
const { eventId } = req.params;
// Admins can access any gallery
if (req.auth.type === 'admin') {
return next();
}
// Gallery tokens can only access their own event
if (req.auth.type === 'gallery') {
if (req.auth.event.id !== parseInt(eventId)) {
return res.status(403).json({ error: 'Access denied' });
}
return next();
}
res.status(403).json({ error: 'Access denied' });
} catch (error) {
res.status(500).json({ error: 'Access verification failed' });
}
}
module.exports = {
adminAuth,
galleryAuth,
photoAuth,
verifyGalleryAccess
};
+231 -69
View File
@@ -1,98 +1,260 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
/**
* Enhanced admin authentication middleware with revocation checking
*/
async function adminAuth(req, res, next) {
try {
const token = getAdminTokenFromRequest(req);
if (!token) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth attempt without token', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent']
});
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
// Try to verify with issuer first, fallback to no issuer for backward compatibility
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth'
});
} catch (issuerError) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} else {
throw issuerError;
}
}
} catch (jwtError) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('JWT validation failed', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
error: jwtError.name,
message: jwtError.message,
timestamp: new Date().toISOString()
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
if (jwtError.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
logger.warn('Revoked token used', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Verify token type
if (decoded.type !== 'admin') {
logger.warn('Non-admin token used for admin endpoint', {
userId: decoded.id,
tokenType: decoded.type
});
return res.status(403).json({ error: 'Insufficient permissions' });
}
// IP validation (optional - can be strict or just log)
const currentIp = req.ip || req.connection.remoteAddress;
if (decoded.ip && decoded.ip !== currentIp) {
logger.warn('Token used from different IP', {
userId: decoded.id,
tokenIp: decoded.ip,
currentIp: currentIp
});
}
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth failed - user not found or inactive', {
ip: clientIp,
userId: decoded.id,
path: req.path,
method: req.method,
timestamp: new Date().toISOString()
});
return res.status(401).json({ error: 'Invalid token' });
}
req.admin = admin;
// 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({
error: 'Token invalid due to password change',
code: 'PASSWORD_CHANGED'
});
}
}
// Add user info to request
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email
};
req.token = token; // Store token for potential revocation
next();
} catch (error) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.error('Admin auth middleware error', {
ip: clientIp,
path: req.path,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
res.status(401).json({ error: 'Invalid token' });
logger.error('Auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
module.exports = { adminAuth };
/**
* Enhanced gallery authentication middleware with revocation checking
*/
async function galleryAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid session' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
}
// Check if event still exists and is active
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Check if gallery has expired
if (new Date(event.expires_at) < new Date()) {
return res.status(410).json({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
}
// Add event info to request
req.event = event;
req.galleryToken = decoded;
req.token = token;
next();
} catch (error) {
logger.error('Gallery auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Photo access authentication
* Validates both admin and gallery tokens for photo access
*/
async function photoAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
req.auth = { type: 'admin', user: admin };
} else if (decoded.type === 'gallery') {
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// For gallery tokens, ensure they can only access their event's photos
req.auth = { type: 'gallery', event: event };
} else {
return res.status(403).json({ error: 'Invalid token type' });
}
next();
} catch (error) {
logger.error('Photo auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Verify gallery access for specific operations
*/
async function verifyGalleryAccess(req, res, next) {
try {
if (!req.auth) {
return res.status(401).json({ error: 'Authentication required' });
}
const { eventId } = req.params;
// Admins can access any gallery
if (req.auth.type === 'admin') {
return next();
}
// Gallery tokens can only access their own event
if (req.auth.type === 'gallery') {
if (req.auth.event.id !== parseInt(eventId)) {
return res.status(403).json({ error: 'Access denied' });
}
return next();
}
res.status(403).json({ error: 'Access denied' });
} catch (error) {
res.status(500).json({ error: 'Access verification failed' });
}
}
module.exports = {
adminAuth,
galleryAuth,
photoAuth,
verifyGalleryAccess
};
@@ -27,7 +27,7 @@ jest.mock('../../database/db', () => {
};
});
jest.mock('../../middleware/auth-enhanced-v2', () => ({
jest.mock('../../middleware/auth', () => ({
adminAuth: (_req, _res, next) => {
_req.admin = { id: 1, username: 'admin' };
next();
@@ -24,7 +24,7 @@ jest.mock('../../database/db', () => {
return { db: dbMock };
});
jest.mock('../../middleware/auth-enhanced-v2', () => ({
jest.mock('../../middleware/auth', () => ({
adminAuth: (_req, _res, next) => next(),
}));
+1 -1
View File
@@ -3,7 +3,7 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
const router = express.Router();
+1 -1
View File
@@ -2,7 +2,7 @@ const express = require('express');
const bcrypt = require('bcrypt');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const { endSession } = require('../middleware/sessionTimeout');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const router = express.Router();
+1 -1
View File
@@ -1,7 +1,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get all CMS pages
+1 -1
View File
@@ -2,7 +2,7 @@ const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get all global categories
+1 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const router = express.Router();
+1 -1
View File
@@ -2,7 +2,7 @@ const express = require('express');
const nodemailer = require('nodemailer');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get email configuration
+1 -1
View File
@@ -5,7 +5,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const eventRenameService = require('../services/eventRenameService');
const router = express.Router();
+5 -38
View File
@@ -2,7 +2,7 @@ const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
const bcrypt = require('bcrypt');
const crypto = require('crypto');
@@ -15,6 +15,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
@@ -58,43 +59,9 @@ const getEventFieldRequirements = async () => {
}
};
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
}
return defaultValue;
};
const getCustomerNameFromPayload = (payload = {}) => {
if (typeof payload.customer_name === 'string') {
const trimmed = payload.customer_name.trim();
return trimmed || null;
}
return null;
};
const getCustomerEmailFromPayload = (payload = {}) => {
if (typeof payload.customer_email === 'string') {
const trimmed = payload.customer_email.trim();
return trimmed || null;
}
return null;
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
+1 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
+1 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const router = express.Router();
// Get notifications (unread activity logs)
+1 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
-393
View File
@@ -1,393 +0,0 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
checkSuspiciousActivity,
getGenericAuthError
} = require('../utils/authSecurity');
const {
validatePasswordInContext,
getBcryptRounds,
logPasswordValidationFailure
} = require('../utils/passwordValidation');
const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router();
// Admin login with enhanced security
router.post('/admin/login', [
body('username').notEmpty().trim(),
body('password').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
const lockoutStatus = await checkAccountLockout(username);
if (lockoutStatus.isLocked) {
logger.warn('Login attempt on locked account', { username, ipAddress });
return res.status(423).json({
error: 'Account temporarily locked due to too many failed attempts',
retryAfter: lockoutStatus.remainingTime
});
}
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
// Check for suspicious activity
const isSuspicious = await checkSuspiciousActivity(username, ipAddress);
if (isSuspicious) {
// Still allow login but log it
logger.warn('Suspicious login pattern detected', { username, ipAddress });
}
const admin = await db('admin_users')
.where({ username })
.orWhere({ email: username })
.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({
last_login: new Date(),
last_login_ip: ipAddress
});
// Generate token with additional claims
const token = jwt.sign({
id: admin.id,
username: admin.username,
type: 'admin',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
res.json({
token,
user: {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false
}
});
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Admin password change with validation
router.post('/admin/change-password', [
body('currentPassword').notEmpty(),
body('newPassword').notEmpty(),
body('confirmPassword').notEmpty()
.custom((value, { req }) => value === req.body.newPassword)
.withMessage('Passwords do not match')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { currentPassword, newPassword } = req.body;
const adminId = req.admin.id; // From auth middleware
// Get admin user
const admin = await db('admin_users').where({ id: adminId }).first();
if (!admin) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const validPassword = await bcrypt.compare(currentPassword, admin.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
// Validate new password
const passwordValidation = validatePasswordInContext(newPassword, 'admin', {
username: admin.username,
email: admin.email
});
if (!passwordValidation.valid) {
logPasswordValidationFailure('admin_password_change', passwordValidation.errors, {
userId: adminId,
username: admin.username
});
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
// Hash new password with configurable rounds
const hashedPassword = await bcrypt.hash(newPassword, getBcryptRounds());
// Update password and track change time
await db('admin_users').where('id', adminId).update({
password_hash: hashedPassword,
password_changed_at: new Date(),
must_change_password: false
});
// Log password change
logger.info('Admin password changed', {
userId: adminId,
username: admin.username,
ip: ipAddress
});
res.json({
message: 'Password changed successfully',
score: passwordValidation.score
});
} catch (error) {
logger.error('Password change error:', error);
res.status(500).json({ error: 'Failed to change password' });
}
});
// Logout endpoint
router.post('/logout', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (token) {
// End the session
endSession(token);
// Log the logout
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
logger.info('User logged out', {
userId: decoded.id,
username: decoded.username,
type: decoded.type
});
} catch (err) {
// Token might be invalid, but still process logout
}
}
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Gallery password verification with enhanced security
router.post('/gallery/verify', [
body('slug').notEmpty().trim(),
body('password').optional().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
}
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
if (requiresPassword) {
if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
} else {
logger.info('Public gallery access granted without password', { slug, ipAddress });
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
}
// Generate session token with additional security info
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
res.json({
token,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
logger.error('Gallery verification error:', error);
res.status(500).json({ error: 'Verification failed' });
}
});
// Get current session info
router.get('/session', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Calculate remaining time
const now = Date.now() / 1000;
const remainingTime = Math.max(0, decoded.exp - now);
res.json({
valid: true,
type: decoded.type,
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug
});
} catch (err) {
res.json({
valid: false,
error: 'Invalid or expired token'
});
}
} catch (error) {
res.status(500).json({ error: 'Session check failed' });
}
});
// Password strength check endpoint (for real-time validation)
router.post('/password-strength', [
body('password').notEmpty(),
body('context').isIn(['admin', 'gallery']).optional()
], async (req, res) => {
try {
const { password, context = 'gallery' } = req.body;
// Get user data if available (for context-aware validation)
const userData = {};
if (context === 'admin' && req.admin) {
userData.username = req.admin.username;
userData.email = req.admin.email;
}
const validation = validatePasswordInContext(password, context, userData);
res.json({
valid: validation.valid,
score: validation.score,
errors: validation.errors,
feedback: validation.feedback
});
} catch (error) {
res.status(500).json({ error: 'Failed to check password strength' });
}
});
module.exports = router;
-395
View File
@@ -1,395 +0,0 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
checkSuspiciousActivity,
getGenericAuthError
} = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger');
const {
setAdminAuthCookie,
clearAdminAuthCookie,
setGalleryAuthCookies,
clearGalleryAuthCookies,
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router();
// Admin login with enhanced security
router.post('/admin/login', [
body('username').notEmpty().trim(),
body('password').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
const lockoutStatus = await checkAccountLockout(username);
if (lockoutStatus.isLocked) {
logger.warn('Login attempt on locked account', { username, ipAddress });
return res.status(423).json({
error: 'Account temporarily locked due to too many failed attempts',
retryAfter: lockoutStatus.remainingTime
});
}
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
// Check for suspicious activity
const isSuspicious = await checkSuspiciousActivity(username, ipAddress);
if (isSuspicious) {
// Still allow login but log it
logger.warn('Suspicious login pattern detected', { username, ipAddress });
}
const admin = await db('admin_users')
.where({ username })
.orWhere({ email: username })
.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({
last_login: new Date(),
last_login_ip: ipAddress
});
// Generate token with additional claims
const token = jwt.sign({
id: admin.id,
username: admin.username,
type: 'admin',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, token);
res.json({
token,
user: {
id: admin.id,
username: admin.username,
email: admin.email,
mustChangePassword: admin.must_change_password || false
}
});
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Logout endpoint
router.post('/logout', async (req, res) => {
try {
const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req);
const token = adminToken || galleryToken;
if (token) {
// End the session
endSession(token);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
logger.info('User logged out', {
userId: decoded.id,
username: decoded.username,
type: decoded.type
});
if (decoded.type === 'admin') {
clearAdminAuthCookie(res);
} else if (decoded.type === 'gallery') {
clearGalleryAuthCookies(res, decoded.eventSlug);
}
} catch (err) {
// Token might be invalid, but still process logout and clear cookies
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
}
} else {
// No token found, but ensure cookies are cleared
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
}
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Gallery password verification with enhanced security
router.post('/gallery/verify', [
body('slug').notEmpty().trim(),
body('password').optional().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
} else {
logger.info('Public gallery access granted without password', { slug, ipAddress });
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
}
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setGalleryAuthCookies(res, token, event.slug);
res.json({
token,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
logger.error('Gallery verification error:', error);
res.status(500).json({ error: 'Verification failed' });
}
});
// Share link authentication (token-based)
router.post('/gallery/share-login', [
body('slug').notEmpty().trim(),
body('token').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug, token } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' });
}
const jwtToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
token: jwtToken,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
logger.error('Share link authentication error:', error);
res.status(500).json({ error: 'Share link login failed' });
}
});
// Gallery logout to clear cookies
router.post('/gallery/logout', async (req, res) => {
try {
const { slug } = req.body || {};
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Gallery logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Get current session info
router.get('/session', async (req, res) => {
try {
const { slug } = req.query;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Calculate remaining time
const now = Date.now() / 1000;
const remainingTime = Math.max(0, decoded.exp - now);
res.json({
valid: true,
type: decoded.type,
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username
});
} catch (err) {
res.json({
valid: false,
error: 'Invalid or expired token'
});
}
} catch (error) {
res.status(500).json({ error: 'Session check failed' });
}
});
module.exports = router;
+429 -55
View File
@@ -5,11 +5,35 @@ const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
trackSuccessfulLogin,
checkAccountLockout,
checkSuspiciousActivity,
getGenericAuthError
} = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger');
const {
setAdminAuthCookie,
clearAdminAuthCookie,
setGalleryAuthCookies,
clearGalleryAuthCookies,
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const { getClientIp } = require('../utils/requestIp');
const {
validatePasswordInContext,
getBcryptRounds,
logPasswordValidationFailure
} = require('../utils/passwordValidation');
const router = express.Router();
// Admin login
// Admin login with enhanced security
router.post('/admin/login', [
body('username').notEmpty(),
body('username').notEmpty().trim(),
body('password').notEmpty()
], async (req, res) => {
try {
@@ -19,30 +43,71 @@ router.post('/admin/login', [
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
const lockoutStatus = await checkAccountLockout(username);
if (lockoutStatus.isLocked) {
logger.warn('Login attempt on locked account', { username, ipAddress });
return res.status(423).json({
error: 'Account temporarily locked due to too many failed attempts',
retryAfter: lockoutStatus.remainingTime
});
}
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
// Check for suspicious activity
const isSuspicious = await checkSuspiciousActivity(username, ipAddress);
if (isSuspicious) {
// Still allow login but log it
logger.warn('Suspicious login pattern detected', { username, ipAddress });
}
const admin = await db('admin_users')
.where({ username })
.orWhere({ email: username })
.first();
// Use generic error to prevent user enumeration
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
if (!admin.is_active) {
return res.status(401).json({ error: 'Account disabled' });
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
// Update last login
await db('admin_users').where('id', admin.id).update({ last_login: new Date() });
// Successful login
await trackSuccessfulLogin(username, ipAddress, userAgent);
const token = jwt.sign({ id: admin.id, type: 'admin' }, process.env.JWT_SECRET, { expiresIn: '24h' });
// Update last login and login metadata
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({
id: admin.id,
username: admin.username,
type: 'admin',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, token);
res.json({
token,
@@ -54,14 +119,57 @@ router.post('/admin/login', [
}
});
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
// Gallery password verification
// Logout endpoint
router.post('/logout', async (req, res) => {
try {
const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req);
const token = adminToken || galleryToken;
if (token) {
// End the session
endSession(token);
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
logger.info('User logged out', {
userId: decoded.id,
username: decoded.username,
type: decoded.type
});
if (decoded.type === 'admin') {
clearAdminAuthCookie(res);
} else if (decoded.type === 'gallery') {
clearGalleryAuthCookies(res, decoded.eventSlug);
}
} catch (err) {
// Token might be invalid, but still process logout and clear cookies
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
}
} else {
// No token found, but ensure cookies are cleared
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
}
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Gallery password verification with enhanced security
router.post('/gallery/verify', [
body('slug').notEmpty(),
body('password').notEmpty()
body('slug').notEmpty().trim(),
body('password').optional().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -70,67 +178,333 @@ router.post('/gallery/verify', [
}
const { slug, password, recaptchaToken } = req.body;
// Verify reCAPTCHA - temporarily disabled for testing
// const recaptchaValid = await verifyRecaptcha(recaptchaToken);
// if (!recaptchaValid) {
// return res.status(400).json({ error: 'reCAPTCHA verification failed' });
// }
const event = await db('events').where({ slug: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).select('*').first();
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'login_fail'
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
} else {
logger.info('Public gallery access granted without password', { slug, ipAddress });
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
return res.status(401).json({ error: 'Invalid password' });
}
// Log successful access
await db('access_logs').insert({
event_id: event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'login_success'
});
// Generate session token
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery'
}, process.env.JWT_SECRET, { expiresIn: '24h' });
const responseEvent = {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
hero_photo_id: event.hero_photo_id,
allow_downloads: event.allow_downloads
};
console.log('Auth response event:', JSON.stringify(responseEvent, null, 2));
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setGalleryAuthCookies(res, token, event.slug);
res.json({
token,
event: responseEvent
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
logger.error('Gallery verification error:', error);
res.status(500).json({ error: 'Verification failed' });
}
});
// Share link authentication (token-based)
router.post('/gallery/share-login', [
body('slug').notEmpty().trim(),
body('token').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug, token } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' });
}
const jwtToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
token: jwtToken,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
logger.error('Share link authentication error:', error);
res.status(500).json({ error: 'Share link login failed' });
}
});
// Gallery logout to clear cookies
router.post('/gallery/logout', async (req, res) => {
try {
const { slug } = req.body || {};
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Gallery logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Get current session info
router.get('/session', async (req, res) => {
try {
const { slug } = req.query;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Calculate remaining time
const now = Date.now() / 1000;
const remainingTime = Math.max(0, decoded.exp - now);
res.json({
valid: true,
type: decoded.type,
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username
});
} catch (err) {
res.json({
valid: false,
error: 'Invalid or expired token'
});
}
} catch (error) {
res.status(500).json({ error: 'Session check failed' });
}
});
// Admin password change with validation
router.post('/admin/change-password', [
body('currentPassword').notEmpty(),
body('newPassword').notEmpty(),
body('confirmPassword').notEmpty()
.custom((value, { req }) => value === req.body.newPassword)
.withMessage('Passwords do not match')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { currentPassword, newPassword } = req.body;
const ipAddress = getClientIp(req);
// Get admin from request (should be set by auth middleware)
if (!req.admin) {
return res.status(401).json({ error: 'Authentication required' });
}
const adminId = req.admin.id;
// Get admin user
const admin = await db('admin_users').where({ id: adminId }).first();
if (!admin) {
return res.status(404).json({ error: 'User not found' });
}
// Verify current password
const validPassword = await bcrypt.compare(currentPassword, admin.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
// Validate new password
const passwordValidation = validatePasswordInContext(newPassword, 'admin', {
username: admin.username,
email: admin.email
});
if (!passwordValidation.valid) {
logPasswordValidationFailure('admin_password_change', passwordValidation.errors, {
userId: adminId,
username: admin.username
});
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
// Hash new password with configurable rounds
const hashedPassword = await bcrypt.hash(newPassword, getBcryptRounds());
// Update password and track change time
await db('admin_users').where('id', adminId).update({
password_hash: hashedPassword,
password_changed_at: new Date(),
must_change_password: false
});
// Log password change
logger.info('Admin password changed', {
userId: adminId,
username: admin.username,
ip: ipAddress
});
res.json({
message: 'Password changed successfully',
score: passwordValidation.score
});
} catch (error) {
logger.error('Password change error:', error);
res.status(500).json({ error: 'Failed to change password' });
}
});
// Password strength check endpoint (for real-time validation)
router.post('/password-strength', [
body('password').notEmpty(),
body('context').isIn(['admin', 'gallery']).optional()
], async (req, res) => {
try {
const { password, context = 'gallery' } = req.body;
// Get user data if available (for context-aware validation)
const userData = {};
if (context === 'admin' && req.admin) {
userData.username = req.admin.username;
userData.email = req.admin.email;
}
const validation = validatePasswordInContext(password, context, userData);
res.json({
valid: validation.valid,
score: validation.score,
errors: validation.errors,
feedback: validation.feedback
});
} catch (error) {
res.status(500).json({ error: 'Failed to check password strength' });
}
});
module.exports = router;
+5 -38
View File
@@ -5,49 +5,16 @@ const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { adminAuth } = require('../middleware/auth');
const fs = require('fs').promises;
const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
}
return defaultValue;
};
const getCustomerNameFromPayload = (payload = {}) => {
if (typeof payload.customer_name === 'string') {
const trimmed = payload.customer_name.trim();
return trimmed || null;
}
return null;
};
const getCustomerEmailFromPayload = (payload = {}) => {
if (typeof payload.customer_email === 'string') {
const trimmed = payload.customer_email.trim();
return trimmed || null;
}
return null;
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
+401
View File
@@ -0,0 +1,401 @@
/**
* Event Service Layer
* Handles all event-related business logic
*
* @module services/eventService
*/
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
// Cache for schema detection
let customerColumnCache = null;
/**
* Check if the database has the new customer_email column
* @returns {Promise<boolean>}
*/
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
return false;
}
};
/**
* Map event for API response (normalize customer fields)
* @param {Object} event - Database event object
* @returns {Object} - Normalized event object
*/
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
};
};
/**
* Generate a unique slug for an event
* @param {string} eventType
* @param {string} eventName
* @param {string} eventDate
* @returns {Promise<string>}
*/
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
return slug;
};
/**
* Create event storage folders
* @param {string} slug - Event slug
* @returns {Promise<string>} - Path to event folder
*/
const createEventFolders = async (slug) => {
const storagePath = getStoragePath();
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
return eventPath;
};
/**
* Create a new event
* @param {Object} eventData - Event data
* @returns {Promise<Object>} - Created event
*/
const createEvent = async (eventData) => {
const {
event_type,
event_name,
event_date,
customer_name,
customer_email,
admin_email,
password,
require_password = true,
welcome_message,
color_theme,
expiration_days = 30,
// Feedback settings
feedback_enabled,
allow_ratings,
allow_likes,
allow_comments,
allow_favorites,
require_name_email,
moderate_comments,
show_feedback_to_guests,
// Upload settings
allow_user_uploads,
upload_category_id
} = eventData;
const requirePassword = parseBooleanInput(require_password, true);
const customerColumnsAvailable = await hasCustomerContactColumns();
// Validate password if required
if (requirePassword) {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
const error = new Error('Password does not meet security requirements');
error.code = 'PASSWORD_INVALID';
error.details = passwordValidation.errors;
error.score = passwordValidation.score;
error.feedback = passwordValidation.feedback;
throw error;
}
}
// Generate unique slug
const slug = await generateUniqueSlug(event_type, event_name, event_date);
// Generate share link
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date
const expires_at = new Date(event_date);
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
await createEventFolders(slug);
// Build insert data
const insertData = {
slug,
event_type,
event_name,
event_date,
...(customerColumnsAvailable ? { customer_name, customer_email } : {}),
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword),
// Feedback settings
feedback_enabled: feedback_enabled !== undefined ? formatBoolean(feedback_enabled) : undefined,
allow_ratings: allow_ratings !== undefined ? formatBoolean(allow_ratings) : undefined,
allow_likes: allow_likes !== undefined ? formatBoolean(allow_likes) : undefined,
allow_comments: allow_comments !== undefined ? formatBoolean(allow_comments) : undefined,
allow_favorites: allow_favorites !== undefined ? formatBoolean(allow_favorites) : undefined,
require_name_email: require_name_email !== undefined ? formatBoolean(require_name_email) : undefined,
moderate_comments: moderate_comments !== undefined ? formatBoolean(moderate_comments) : undefined,
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
// Upload settings
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
upload_category_id: upload_category_id || null
};
// Remove undefined values
Object.keys(insertData).forEach(key => {
if (insertData[key] === undefined) {
delete insertData[key];
}
});
// Insert into database
const insertResult = await db('events').insert(insertData).returning('id');
const eventId = insertResult[0]?.id || insertResult[0];
return {
id: eventId,
slug,
share_link: shareUrl,
expires_at,
require_password: requirePassword,
customer_name,
customer_email
};
};
/**
* Get all events with optional filtering
* @param {Object} options - Filter options
* @param {string} options.status - 'all', 'active', or 'archived'
* @returns {Promise<Array>} - Array of events
*/
const getAllEvents = async (options = {}) => {
const { status = 'all' } = options;
let query = db('events').select('*');
if (status === 'active') {
query = query.where('is_active', formatBoolean(true));
} else if (status === 'archived') {
query = query.where('is_archived', formatBoolean(true));
}
const events = await query.orderBy('created_at', 'desc');
// Add photo counts
for (const event of events) {
const photoCount = await db('photos').where('event_id', event.id).count('id as count').first();
event.photo_count = photoCount.count;
}
return events.map(mapEventForApi);
};
/**
* Get a single event by ID
* @param {number} id - Event ID
* @returns {Promise<Object|null>}
*/
const getEventById = async (id) => {
const event = await db('events').where('id', id).first();
return event ? mapEventForApi(event) : null;
};
/**
* Get a single event by slug
* @param {string} slug - Event slug
* @returns {Promise<Object|null>}
*/
const getEventBySlug = async (slug) => {
const event = await db('events').where('slug', slug).first();
return event ? mapEventForApi(event) : null;
};
/**
* Update an event
* @param {number} id - Event ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} - Updated event
*/
const updateEvent = async (id, updates) => {
const customerColumnsAvailable = await hasCustomerContactColumns();
// Don't allow updating certain fields
delete updates.id;
delete updates.slug;
delete updates.created_at;
delete updates.password_confirmation;
// Handle legacy field names
if (updates.host_name || updates.host_email) {
throw new Error('host_name and host_email are no longer supported. Use customer_name and customer_email instead.');
}
// Handle customer name update
if (updates.customer_name !== undefined) {
const nextName = parseStringInput(updates.customer_name);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
// Handle customer email update
if (updates.customer_email !== undefined) {
const nextEmail = parseStringInput(updates.customer_email);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
// Handle require_password update
if (updates.require_password !== undefined) {
const requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
// Get current event to check password requirements
const event = await db('events').where('id', id).first();
const currentRequirePassword = parseBooleanInput(event.require_password, true);
// If enabling password and it was previously disabled, require a new password
if (requirePasswordUpdate === true && !currentRequirePassword && !updates.password) {
throw new Error('Password must be provided when enabling password requirement.');
}
// If disabling password, generate a random hash
if (requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
}
// Handle password update
if (updates.password) {
updates.password_hash = await bcrypt.hash(updates.password, getBcryptRounds());
delete updates.password;
}
await db('events').where('id', id).update(updates);
return { success: true };
};
/**
* Soft delete an event (mark as inactive)
* @param {number} id - Event ID
* @returns {Promise<Object>}
*/
const deleteEvent = async (id) => {
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
return { success: true };
};
/**
* Extend event expiration
* @param {number} id - Event ID
* @param {number} days - Days to extend
* @returns {Promise<Object>}
*/
const extendExpiration = async (id, days) => {
const event = await db('events').where('id', id).first();
if (!event) {
throw new Error('Event not found');
}
const newExpiration = new Date(event.expires_at);
newExpiration.setDate(newExpiration.getDate() + days);
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: formatBoolean(true) // Reactivate if expired
});
return { expires_at: newExpiration };
};
module.exports = {
// Core CRUD
createEvent,
getAllEvents,
getEventById,
getEventBySlug,
updateEvent,
deleteEvent,
extendExpiration,
// Utilities
mapEventForApi,
hasCustomerContactColumns,
generateUniqueSlug,
createEventFolders
};
+204
View File
@@ -0,0 +1,204 @@
/**
* Shared Parser Utilities
* Pure functions for parsing and transforming input values
*
* @module utils/parsers
*/
/**
* Parse any input value to boolean with configurable default
* Handles: boolean, number, string representations
*
* @param {*} value - Input value to parse
* @param {boolean} [defaultValue=true] - Default if value is undefined/null
* @returns {boolean}
*
* @example
* parseBooleanInput(true) // true
* parseBooleanInput('false') // false
* parseBooleanInput('1') // true
* parseBooleanInput(0) // false
* parseBooleanInput(undefined, false) // false
*/
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
if (Number.isNaN(value)) return defaultValue;
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no', 'off', ''].includes(normalized)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
}
return defaultValue;
};
/**
* Parse numeric input with validation and bounds
*
* @param {*} value - Input value to parse
* @param {number} defaultValue - Default if invalid
* @param {Object} [options] - Bounds options
* @param {number} [options.min] - Minimum allowed value
* @param {number} [options.max] - Maximum allowed value
* @returns {number}
*
* @example
* parseNumberInput('42', 0) // 42
* parseNumberInput('abc', 10) // 10
* parseNumberInput(5, 0, { min: 10 }) // 10
* parseNumberInput(100, 0, { max: 50 }) // 50
*/
const parseNumberInput = (value, defaultValue, options = {}) => {
const { min, max } = options;
if (value === undefined || value === null || value === '') {
return defaultValue;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return defaultValue;
}
let result = parsed;
if (min !== undefined && result < min) result = min;
if (max !== undefined && result > max) result = max;
return result;
};
/**
* Parse string input with trimming and null handling
*
* @param {*} value - Input value
* @param {string|null} [defaultValue=null] - Default if empty
* @returns {string|null}
*
* @example
* parseStringInput(' hello ') // 'hello'
* parseStringInput('') // null
* parseStringInput(null, 'default') // 'default'
*/
const parseStringInput = (value, defaultValue = null) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || defaultValue;
}
return String(value);
};
/**
* Parse JSON string safely
* Returns the parsed value or default if parsing fails
*
* @param {*} value - JSON string or already parsed value
* @param {*} [defaultValue=null] - Default if parsing fails
* @returns {*}
*
* @example
* parseJsonInput('{"a":1}') // { a: 1 }
* parseJsonInput({ a: 1 }) // { a: 1 } (passthrough)
* parseJsonInput('invalid', {}) // {}
*/
const parseJsonInput = (value, defaultValue = null) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value !== 'string') {
return value; // Already parsed
}
try {
return JSON.parse(value);
} catch {
return defaultValue;
}
};
/**
* Parse email input with validation
*
* @param {*} value - Input value
* @returns {string|null} - Valid email or null
*/
const parseEmailInput = (value) => {
const str = parseStringInput(value);
if (!str) return null;
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(str) ? str.toLowerCase() : null;
};
/**
* Parse date input to ISO string
*
* @param {*} value - Date string, Date object, or timestamp
* @param {string|null} [defaultValue=null] - Default if invalid
* @returns {string|null} - ISO date string (YYYY-MM-DD) or null
*/
const parseDateInput = (value, defaultValue = null) => {
if (value === undefined || value === null || value === '') {
return defaultValue;
}
const date = new Date(value);
if (isNaN(date.getTime())) {
return defaultValue;
}
// Return YYYY-MM-DD format
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Parse array input (handles JSON strings and arrays)
*
* @param {*} value - Array or JSON string
* @param {Array} [defaultValue=[]] - Default if invalid
* @returns {Array}
*/
const parseArrayInput = (value, defaultValue = []) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (Array.isArray(value)) {
return value;
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : defaultValue;
} catch {
// Try comma-separated
return value.split(',').map(s => s.trim()).filter(Boolean);
}
}
return defaultValue;
};
module.exports = {
parseBooleanInput,
parseNumberInput,
parseStringInput,
parseJsonInput,
parseEmailInput,
parseDateInput,
parseArrayInput
};
+179 -44
View File
@@ -140,6 +140,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -488,6 +489,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -511,6 +513,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1626,6 +1629,44 @@
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/dom/node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -1693,6 +1734,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -1789,6 +1831,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz",
"integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -2067,6 +2110,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -2148,6 +2192,13 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2282,6 +2333,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -2293,6 +2345,7 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@@ -2370,6 +2423,7 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0",
@@ -2736,6 +2790,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2780,6 +2835,16 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@@ -2817,6 +2882,19 @@
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -2979,6 +3057,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -3411,6 +3490,16 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -3596,6 +3685,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4170,6 +4260,7 @@
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
"integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=12.0.0"
}
@@ -4252,6 +4343,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@@ -4424,6 +4516,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -4684,6 +4777,7 @@
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz",
"integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/hast": "^2.0.0",
"fault": "^2.0.0",
@@ -4713,6 +4807,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4779,6 +4883,19 @@
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -5088,13 +5205,14 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8.6"
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
@@ -5140,6 +5258,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5293,6 +5412,41 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5416,6 +5570,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -5445,6 +5600,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -5493,6 +5649,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
"integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -5550,6 +5707,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -5588,6 +5746,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5743,6 +5902,19 @@
"node": ">=8.10.0"
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -6175,19 +6347,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinypool": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
@@ -6316,8 +6475,9 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -6419,6 +6579,7 @@
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -6511,19 +6672,6 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
@@ -6597,19 +6745,6 @@
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+4 -4
View File
@@ -14,7 +14,7 @@ import {
AdminLoginPage,
AdminDashboard,
EventsListPage,
CreateEventPageEnhanced as CreateEventPage,
CreateEventPage,
EventDetailsPage,
EventFeedbackPage,
EmailConfigPage,
@@ -22,9 +22,9 @@ import {
AnalyticsPage,
BrandingPage,
SettingsPage,
BackupManagement
BackupManagement,
CMSPage
} from './pages/admin';
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -127,7 +127,7 @@ function App() {
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPageEnhanced />} />
<Route path="cms" element={<CMSPage />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
+149 -69
View File
@@ -1,8 +1,9 @@
import React, { useMemo, useState } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { FileText, Globe, Sparkles, ShieldCheck } from 'lucide-react';
import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { debounce } from 'lodash';
import DOMPurify from 'dompurify';
import { Button, Card, Input, Loading } from '../../components/common';
@@ -17,6 +18,9 @@ export const CMSPage: React.FC = () => {
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isAutoSaving, setIsAutoSaving] = useState(false);
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
const [publicSiteHtml, setPublicSiteHtml] = useState('');
const [publicSiteCss, setPublicSiteCss] = useState('');
@@ -39,63 +43,25 @@ export const CMSPage: React.FC = () => {
queryFn: () => settingsService.getPublicSiteDefaults(),
});
React.useEffect(() => {
if (publicSiteDefaults) {
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
setPublicSiteBranding(publicSiteDefaults.branding);
}
}, [publicSiteDefaults]);
React.useEffect(() => {
if (!adminSettings) {
return;
}
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
}, [adminSettings]);
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
cmsService.updatePage(slug, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
toast.success(t('cms.pageUpdated'));
setHasUnsavedChanges(false);
setLastSaved(new Date());
setIsAutoSaving(false);
if (!isAutoSaving) {
toast.success(t('cms.pageUpdated'));
}
},
onError: () => {
setIsAutoSaving(false);
toast.error(t('toast.saveError'));
},
});
// Load page data when selection changes
React.useEffect(() => {
if (pages) {
const page = pages.find(p => p.slug === selectedPage);
if (page) {
setEditForm(page);
}
}
}, [pages, selectedPage]);
const handleSave = () => {
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
};
const handleContentChange = (content: string) => {
const field = editingLang === 'de' ? 'content_de' : 'content_en';
setEditForm(prev => ({ ...prev, [field]: content }));
};
const handleTitleChange = (title: string) => {
const field = editingLang === 'de' ? 'title_de' : 'title_en';
setEditForm(prev => ({ ...prev, [field]: title }));
};
const publicSiteSaveMutation = useMutation({
mutationFn: async () => {
const trimmedHtml = publicSiteHtml.trim();
@@ -140,19 +106,95 @@ export const CMSPage: React.FC = () => {
},
onError: () => {
toast.error(t('settings.publicSite.resetError'));
}
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('cms.loadingPages')} />
</div>
);
}
// Auto-save functionality
const autoSave = useCallback(
debounce(() => {
if (hasUnsavedChanges && !updateMutation.isPending) {
setIsAutoSaving(true);
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
}
}, 3000),
[hasUnsavedChanges, editForm, selectedPage]
);
useEffect(() => {
if (publicSiteDefaults) {
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
setPublicSiteBranding(publicSiteDefaults.branding);
}
}, [publicSiteDefaults]);
useEffect(() => {
if (!adminSettings) {
return;
}
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
}, [adminSettings]);
// Trigger auto-save when content changes
useEffect(() => {
if (hasUnsavedChanges) {
autoSave();
}
return () => {
autoSave.cancel();
};
}, [hasUnsavedChanges, autoSave]);
// Load page data when selection changes
React.useEffect(() => {
if (pages) {
const page = pages.find(p => p.slug === selectedPage);
if (page) {
setEditForm(page);
setHasUnsavedChanges(false);
}
}
}, [pages, selectedPage]);
const handleSave = () => {
autoSave.cancel(); // Cancel any pending auto-save
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
};
const handleContentChange = (content: string) => {
const field = editingLang === 'de' ? 'content_de' : 'content_en';
setEditForm(prev => ({ ...prev, [field]: content }));
setHasUnsavedChanges(true);
};
const handleTitleChange = (title: string) => {
const field = editingLang === 'de' ? 'title_de' : 'title_en';
setEditForm(prev => ({ ...prev, [field]: title }));
setHasUnsavedChanges(true);
};
// Warn before leaving with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
const currentPage = pages?.find(p => p.slug === selectedPage);
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
ALLOWED_TAGS: [
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
@@ -204,15 +246,9 @@ export const CMSPage: React.FC = () => {
company_name: branding.companyName || '',
company_tagline: branding.companyTagline || '',
support_email: branding.supportEmail || '',
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
brand_primary_hex: branding.colors.primary,
brand_accent_hex: branding.colors.accent,
brand_background_hex: branding.colors.background,
brand_text_hex: branding.colors.text,
};
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
(_, key: string) => tokens[key] || '');
return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || '');
};
const publicSitePreview = useMemo(() => {
@@ -278,6 +314,14 @@ export const CMSPage: React.FC = () => {
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('cms.loadingPages')} />
</div>
);
}
return (
<div>
<div className="mb-6">
@@ -423,18 +467,28 @@ export const CMSPage: React.FC = () => {
{pages?.map((page) => (
<button
key={page.slug}
onClick={() => setSelectedPage(page.slug)}
onClick={() => {
if (hasUnsavedChanges) {
if (confirm('You have unsaved changes. Do you want to save them?')) {
handleSave();
}
}
setSelectedPage(page.slug);
}}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
selectedPage === page.slug
? 'bg-primary-100 text-primary-700 border border-primary-300'
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
}`}
>
<FileText className="w-5 h-5" />
<div>
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
<FileText className="w-5 h-5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
<p className="text-sm text-neutral-500">/{page.slug}</p>
</div>
{selectedPage === page.slug && hasUnsavedChanges && (
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
)}
</button>
))}
</div>
@@ -463,6 +517,32 @@ export const CMSPage: React.FC = () => {
</a>
</div>
</Card>
{/* Auto-save status */}
{(hasUnsavedChanges || lastSaved) && (
<Card padding="md" className="mt-4">
<div className="text-sm">
{isAutoSaving && (
<div className="flex items-center gap-2 text-neutral-600">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Auto-saving...
</div>
)}
{!isAutoSaving && hasUnsavedChanges && (
<div className="flex items-center gap-2 text-yellow-600">
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
Unsaved changes
</div>
)}
{!hasUnsavedChanges && lastSaved && (
<div className="flex items-center gap-2 text-green-600">
<Clock className="w-4 h-4" />
Saved {new Date(lastSaved).toLocaleTimeString()}
</div>
)}
</div>
</Card>
)}
</div>
{/* Editor */}
@@ -483,7 +563,7 @@ export const CMSPage: React.FC = () => {
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇬🇧 English
English
</button>
<button
onClick={() => setEditingLang('de')}
@@ -493,7 +573,7 @@ export const CMSPage: React.FC = () => {
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
🇩🇪 Deutsch
Deutsch
</button>
</div>
</div>
@@ -1,618 +0,0 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { debounce } from 'lodash';
import DOMPurify from 'dompurify';
import { Button, Card, Input, Loading } from '../../components/common';
import { CMSEditor } from '../../components/admin/CMSEditor';
import { cmsService } from '../../services/cms.service';
import type { CMSPage as CMSPageType } from '../../services/cms.service';
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
export const CMSPageEnhanced: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [isAutoSaving, setIsAutoSaving] = useState(false);
const [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
const [publicSiteHtml, setPublicSiteHtml] = useState('');
const [publicSiteCss, setPublicSiteCss] = useState('');
const [publicSiteBaseCss, setPublicSiteBaseCss] = useState('');
const [publicSiteBranding, setPublicSiteBranding] = useState<PublicSiteBranding | undefined>(undefined);
// Fetch CMS pages
const { data: pages, isLoading } = useQuery({
queryKey: ['cms-pages'],
queryFn: cmsService.getPages,
});
const { data: adminSettings, isLoading: isLoadingAdminSettings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
const { data: publicSiteDefaults, isLoading: isLoadingPublicDefaults } = useQuery({
queryKey: ['public-site-defaults'],
queryFn: () => settingsService.getPublicSiteDefaults(),
});
// Update page mutation
const updateMutation = useMutation({
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
cmsService.updatePage(slug, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
setHasUnsavedChanges(false);
setLastSaved(new Date());
setIsAutoSaving(false);
if (!isAutoSaving) {
toast.success(t('cms.pageUpdated'));
}
},
onError: () => {
setIsAutoSaving(false);
toast.error(t('toast.saveError'));
},
});
const publicSiteSaveMutation = useMutation({
mutationFn: async () => {
const trimmedHtml = publicSiteHtml.trim();
if (publicSiteEnabled && !trimmedHtml) {
throw new Error('PUBLIC_SITE_HTML_REQUIRED');
}
await settingsService.updatePublicSite({
enabled: publicSiteEnabled,
html: trimmedHtml || '',
css: publicSiteCss,
});
},
onSuccess: async () => {
toast.success(t('settings.publicSite.saveSuccess'));
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
]);
},
onError: (error: any) => {
if (error?.message === 'PUBLIC_SITE_HTML_REQUIRED') {
toast.error(t('settings.publicSite.htmlRequired'));
return;
}
toast.error(t('settings.publicSite.saveError'));
},
});
const publicSiteResetMutation = useMutation({
mutationFn: () => settingsService.resetPublicSite(),
onSuccess: async (data) => {
toast.success(t('settings.publicSite.resetSuccess'));
setPublicSiteHtml(data.html || '');
setPublicSiteCss(data.css || '');
setPublicSiteBaseCss(data.baseCss || '');
setPublicSiteBranding(data.branding ?? publicSiteDefaults?.branding);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }),
queryClient.invalidateQueries({ queryKey: ['public-site-defaults'] }),
]);
},
onError: () => {
toast.error(t('settings.publicSite.resetError'));
},
});
// Auto-save functionality
const autoSave = useCallback(
debounce(() => {
if (hasUnsavedChanges && !updateMutation.isPending) {
setIsAutoSaving(true);
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
}
}, 3000),
[hasUnsavedChanges, editForm, selectedPage]
);
useEffect(() => {
if (publicSiteDefaults) {
setPublicSiteBaseCss(publicSiteDefaults.baseCss || '');
setPublicSiteBranding(publicSiteDefaults.branding);
}
}, [publicSiteDefaults]);
useEffect(() => {
if (!adminSettings) {
return;
}
setPublicSiteEnabled(Boolean(adminSettings.general_public_site_enabled));
setPublicSiteHtml((adminSettings.general_public_site_html as string) || '');
setPublicSiteCss((adminSettings.general_public_site_custom_css as string) || '');
}, [adminSettings]);
// Trigger auto-save when content changes
useEffect(() => {
if (hasUnsavedChanges) {
autoSave();
}
return () => {
autoSave.cancel();
};
}, [hasUnsavedChanges, autoSave]);
// Load page data when selection changes
React.useEffect(() => {
if (pages) {
const page = pages.find(p => p.slug === selectedPage);
if (page) {
setEditForm(page);
setHasUnsavedChanges(false);
}
}
}, [pages, selectedPage]);
const handleSave = () => {
autoSave.cancel(); // Cancel any pending auto-save
updateMutation.mutate({
slug: selectedPage,
data: editForm,
});
};
const handleContentChange = (content: string) => {
const field = editingLang === 'de' ? 'content_de' : 'content_en';
setEditForm(prev => ({ ...prev, [field]: content }));
setHasUnsavedChanges(true);
};
const handleTitleChange = (title: string) => {
const field = editingLang === 'de' ? 'title_de' : 'title_en';
setEditForm(prev => ({ ...prev, [field]: title }));
setHasUnsavedChanges(true);
};
// Warn before leaving with unsaved changes
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (hasUnsavedChanges) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);
const currentPage = pages?.find(p => p.slug === selectedPage);
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
ALLOWED_TAGS: [
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span', 'strong', 'sup',
'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'ul'
],
ALLOWED_ATTR: ['class', 'id', 'role', 'aria-label', 'aria-hidden', 'href', 'target', 'rel', 'src', 'alt', 'title', 'loading', 'decoding', 'width', 'height'],
ALLOW_UNKNOWN_PROTOCOLS: false,
ADD_ATTR: ['data-*'],
}), [publicSiteHtml]);
const sanitizeCss = (css: string) => {
if (!css) {
return '';
}
let sanitized = css;
const disallowedPatterns = [
/@import[^;]+;?/gi,
/@charset[^;]+;?/gi,
/expression\s*\([^)]*\)/gi,
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
];
disallowedPatterns.forEach((pattern) => {
sanitized = sanitized.replace(pattern, '');
});
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
const MAX_LENGTH = 100 * 1024;
if (sanitized.length > MAX_LENGTH) {
sanitized = sanitized.slice(0, MAX_LENGTH);
}
return sanitized.trim();
};
const publicSiteSanitizedCss = useMemo(() => sanitizeCss(publicSiteCss || ''), [publicSiteCss]);
const applyBrandTokens = (html: string, branding: PublicSiteBranding | undefined) => {
if (!html || !branding) {
return html;
}
const tokens: Record<string, string> = {
company_name: branding.companyName || '',
company_tagline: branding.companyTagline || '',
support_email: branding.supportEmail || '',
};
return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || '');
};
const publicSitePreview = useMemo(() => {
const branding = publicSiteBranding || publicSiteDefaults?.branding;
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
const inlineStyles = [
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
publicSiteBaseCss,
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
].filter(Boolean).join('\n\n');
const logo = branding?.logoUrl ? `<img src="${branding.logoUrl}" alt="${branding.companyName || 'Brand logo'}" class="brand-logo" loading="lazy" decoding="async" />` : '';
const tagline = branding?.companyTagline ? `<p class="brand-tagline">${branding.companyTagline}</p>` : '';
const support = branding?.supportEmail ? `<a href="mailto:${branding.supportEmail}">${branding.supportEmail}</a>` : '';
const footerNote = branding?.footerText ? `<p>${branding.footerText}</p>` : '';
const displayName = branding?.companyName || 'Celebration Stories';
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>${inlineStyles}</style>
</head>
<body>
<div class="site-shell">
<header class="site-header">
<div class="header-inner">
<div class="brand">
${logo}
<div class="brand-copy">
<p class="brand-label">${displayName}</p>
${tagline}
</div>
</div>
<nav class="site-nav">
<a href="#collections">Collections</a>
<a href="#features">Features</a>
<a href="#stories">Stories</a>
<a href="#contact">Contact</a>
</nav>
</div>
</header>
<main class="site-main">
${substitutedHtml}
</main>
<footer class="site-footer" id="contact">
<div class="footer-inner">
<div>
<h2>${displayName}</h2>
${footerNote}
</div>
<div class="footer-contact">
<span>${support}</span>
</div>
</div>
</footer>
</div>
</body>
</html>`;
}, [publicSiteBranding, publicSiteDefaults, publicSiteSanitizedHtml, publicSiteBaseCss, publicSiteSanitizedCss]);
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loading size="lg" text={t('cms.loadingPages')} />
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900">{t('cms.title')}</h1>
<p className="text-neutral-600 mt-1">{t('cms.subtitle')}</p>
</div>
<div className="mb-8">
<Card className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-primary-600 mb-1">
<Globe className="w-5 h-5" />
<span className="text-sm font-semibold uppercase tracking-wide">{t('settings.publicSite.badge')}</span>
</div>
<h2 className="text-2xl font-semibold text-neutral-900">{t('settings.publicSite.title')}</h2>
<p className="text-neutral-600 mt-1 max-w-2xl">{t('settings.publicSite.subtitle')}</p>
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
className="sr-only"
checked={publicSiteEnabled}
onChange={() => setPublicSiteEnabled((prev) => !prev)}
/>
<span
aria-hidden="true"
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
publicSiteEnabled ? 'bg-primary-600' : 'bg-neutral-300'
}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
publicSiteEnabled ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</span>
<span className="text-sm font-medium text-neutral-700">
{publicSiteEnabled ? t('settings.publicSite.enabled') : t('settings.publicSite.disabled')}
</span>
</label>
</div>
{publicSiteLoading ? (
<div className="flex items-center justify-center min-h-[240px]">
<Loading size="lg" text={t('settings.publicSite.loading')} />
</div>
) : (
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
<Sparkles className="w-4 h-4 text-primary-500" />
{t('settings.publicSite.htmlLabel')}
</label>
<textarea
className="w-full h-64 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
value={publicSiteHtml}
onChange={(event) => setPublicSiteHtml(event.target.value)}
disabled={!publicSiteEnabled}
placeholder={t('settings.publicSite.htmlPlaceholder') || ''}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.publicSite.htmlHelp')}
</p>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-neutral-800 mb-2">
<ShieldCheck className="w-4 h-4 text-primary-500" />
{t('settings.publicSite.cssLabel')}
</label>
<textarea
className="w-full h-48 font-mono text-sm rounded-lg border border-neutral-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:bg-neutral-100 disabled:text-neutral-500"
value={publicSiteCss}
onChange={(event) => setPublicSiteCss(event.target.value)}
disabled={!publicSiteEnabled}
placeholder={t('settings.publicSite.cssPlaceholder') || ''}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.publicSite.cssHelp')}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button
variant="primary"
onClick={() => publicSiteSaveMutation.mutate()}
disabled={publicSiteSaveMutation.isPending}
isLoading={publicSiteSaveMutation.isPending}
>
{publicSiteSaveMutation.isPending ? t('settings.publicSite.saving') : t('settings.publicSite.saveCta')}
</Button>
<Button
variant="secondary"
onClick={() => publicSiteResetMutation.mutate()}
disabled={publicSiteResetMutation.isPending}
isLoading={publicSiteResetMutation.isPending}
>
{publicSiteResetMutation.isPending ? t('settings.publicSite.resetting') : t('settings.publicSite.resetCta')}
</Button>
</div>
<div className="rounded-lg bg-neutral-50 border border-neutral-200 p-3 text-xs text-neutral-600 leading-relaxed">
<p className="font-semibold mb-1">{t('settings.publicSite.sanitizationNotice')}</p>
<p>{t('settings.publicSite.htmlHelp')}</p>
</div>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-800 uppercase tracking-wide">
{t('settings.publicSite.previewTitle')}
</h3>
<span className="text-xs text-neutral-500">{t('settings.publicSite.previewSandboxed')}</span>
</div>
{publicSiteEnabled ? (
<div className="rounded-xl border border-neutral-200 overflow-hidden shadow-sm bg-white">
<iframe
title="public-site-preview"
sandbox="allow-same-origin"
className="w-full h-[480px] bg-white"
srcDoc={publicSitePreview}
/>
</div>
) : (
<div className="rounded-xl border border-dashed border-neutral-300 bg-neutral-50 p-8 text-center text-sm text-neutral-500">
{t('settings.publicSite.previewDisabled')}
</div>
)}
</div>
</div>
)}
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Page Selection */}
<div className="lg:col-span-1">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('cms.pages')}</h2>
<div className="space-y-2">
{pages?.map((page) => (
<button
key={page.slug}
onClick={() => {
if (hasUnsavedChanges) {
if (confirm('You have unsaved changes. Do you want to save them?')) {
handleSave();
}
}
setSelectedPage(page.slug);
}}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
selectedPage === page.slug
? 'bg-primary-100 text-primary-700 border border-primary-300'
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
}`}
>
<FileText className="w-5 h-5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
<p className="text-sm text-neutral-500">/{page.slug}</p>
</div>
{selectedPage === page.slug && hasUnsavedChanges && (
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
)}
</button>
))}
</div>
</Card>
<Card padding="md" className="mt-4">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">{t('cms.previewLinks')}</h3>
<div className="space-y-2 text-sm">
<a
href={`${window.location.origin}/${selectedPage}?lang=en`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
{t('cms.englishVersion')}
</a>
<a
href={`${window.location.origin}/${selectedPage}?lang=de`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-primary-600 hover:text-primary-700"
>
<Globe className="w-4 h-4" />
{t('cms.germanVersion')}
</a>
</div>
</Card>
{/* Auto-save status */}
{(hasUnsavedChanges || lastSaved) && (
<Card padding="md" className="mt-4">
<div className="text-sm">
{isAutoSaving && (
<div className="flex items-center gap-2 text-neutral-600">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Auto-saving...
</div>
)}
{!isAutoSaving && hasUnsavedChanges && (
<div className="flex items-center gap-2 text-yellow-600">
<div className="w-2 h-2 bg-yellow-500 rounded-full" />
Unsaved changes
</div>
)}
{!hasUnsavedChanges && lastSaved && (
<div className="flex items-center gap-2 text-green-600">
<Clock className="w-4 h-4" />
Saved {new Date(lastSaved).toLocaleTimeString()}
</div>
)}
</div>
</Card>
)}
</div>
{/* Editor */}
<div className="lg:col-span-3">
<Card padding="md">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900">
{t('cms.editPage', { page: t(`legal.${selectedPage}`) })}
</h2>
{/* Language Tabs */}
<div className="flex gap-2">
<button
onClick={() => setEditingLang('en')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
English
</button>
<button
onClick={() => setEditingLang('de')}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de'
? 'bg-primary-100 text-primary-700'
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
}`}
>
Deutsch
</button>
</div>
</div>
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('cms.pageTitle')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<Input
value={editingLang === 'en' ? editForm.title_en || '' : editForm.title_de || ''}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder={t('cms.pageTitlePlaceholder')}
/>
</div>
{/* Content */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('cms.pageContent')} ({editingLang === 'en' ? 'English' : 'German'})
</label>
<CMSEditor
content={editingLang === 'en' ? editForm.content_en || '' : editForm.content_de || ''}
onChange={handleContentChange}
onSave={handleSave}
isSaving={updateMutation.isPending}
/>
</div>
</div>
{currentPage?.updated_at && (
<p className="text-xs text-neutral-500 mt-4">
{t('cms.lastUpdated')} {new Date(currentPage.updated_at).toLocaleString()}
</p>
)}
</Card>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -1,709 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Calendar,
Mail,
Lock,
Clock,
ArrowLeft,
Palette,
Eye,
EyeOff
} from 'lucide-react';
import { addDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, PasswordGenerator } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
interface FormData {
event_type: string;
event_name: string;
event_date: string;
customer_name: string;
customer_email: string;
admin_email: string;
require_password: boolean;
password: string;
confirm_password: string;
welcome_message: string;
theme_preset: string;
theme_config: ThemeConfig;
expires_in_days: number;
allow_user_uploads: boolean;
upload_category_id: number | null;
feedback_settings: {
feedback_enabled: boolean;
allow_ratings: boolean;
allow_likes: boolean;
allow_comments: boolean;
allow_favorites: boolean;
require_name_email: boolean;
moderate_comments: boolean;
show_feedback_to_guests: boolean;
enable_rate_limiting: boolean;
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
};
}
const EVENT_TYPE_PRESETS: Record<string, string> = {
wedding: 'elegantWedding',
birthday: 'birthdayFun',
corporate: 'corporateTimeline',
other: 'default'
};
const EVENT_TYPES = [
{ value: 'wedding', labelKey: 'events.types.wedding', emoji: '💒' },
{ value: 'birthday', labelKey: 'events.types.birthday', emoji: '🎂' },
{ value: 'corporate', labelKey: 'events.types.corporate', emoji: '🏢' },
{ value: 'other', labelKey: 'events.types.other', emoji: '📸' },
];
export const CreateEventPageEnhanced: React.FC = () => {
const navigate = useNavigate();
const { t } = useTranslation();
const { format } = useLocalizedDate();
const isMountedRef = useRef(true);
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
// const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const [formData, setFormData] = useState<FormData>({
event_type: 'wedding',
event_name: '',
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
customer_name: '',
customer_email: '',
admin_email: '',
require_password: true,
password: '',
confirm_password: '',
welcome_message: '',
theme_preset: 'elegantWedding',
theme_config: GALLERY_THEME_PRESETS.elegantWedding.config,
expires_in_days: 30,
allow_user_uploads: false,
upload_category_id: null,
feedback_settings: {
feedback_enabled: false,
allow_ratings: true,
allow_likes: true,
allow_comments: true,
allow_favorites: true,
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true,
enable_rate_limiting: true,
rate_limit_window_minutes: 15,
rate_limit_max_requests: 10,
},
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const [showPassword, setShowPassword] = useState(false);
// Fetch categories for user upload selection
const { data: categories } = useQuery({
queryKey: ['categories', 'global'],
queryFn: () => categoriesService.getGlobalCategories()
});
// Fetch default settings
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings()
});
// Fetch public settings for field requirements
const { data: publicSettings } = useQuery({
queryKey: ['public-settings'],
queryFn: () => settingsService.getPublicSettings()
});
// Get field requirements (default to true if not set)
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
// Update default expiration days when settings are loaded
useEffect(() => {
if (settings?.general_default_expiration_days) {
setFormData(prev => ({
...prev,
expires_in_days: settings.general_default_expiration_days
}));
}
}, [settings]);
// Update theme when event type changes
useEffect(() => {
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) {
setFormData(prev => ({
...prev,
theme_preset: recommendedPreset,
theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config
}));
}
}, [formData.event_type]);
const createMutation = useMutation({
mutationFn: eventsService.createEvent,
onSuccess: (data) => {
if (isMountedRef.current) {
toast.success(t('toast.eventCreated'));
navigate(`/admin/events/${data.id}`);
}
},
onError: (error: any) => {
const errorMessage = error.response?.data?.error || error.message || t('errors.eventCreationFailed');
// If validation errors exist, show them
if (error.response?.data?.errors) {
const validationErrors = error.response.data.errors;
validationErrors.forEach((err: any) => {
toast.error(`${err.param}: ${err.msg}`);
});
} else {
toast.error(errorMessage);
}
},
});
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof FormData, string>> = {};
if (!formData.event_name) {
newErrors.event_name = t('validation.eventNameRequired');
}
if (!formData.event_date) {
newErrors.event_date = t('validation.eventDateRequired');
}
// Conditional validation based on settings
if (requireCustomerName && !formData.customer_name) {
newErrors.customer_name = t('validation.hostNameRequired');
}
if (requireCustomerEmail) {
if (!formData.customer_email) {
newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
newErrors.customer_email = t('validation.invalidEmailFormat');
}
} else if (formData.customer_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
// Still validate format if value is provided, even if optional
newErrors.customer_email = t('validation.invalidEmailFormat');
}
if (requireAdminEmail) {
if (!formData.admin_email) {
newErrors.admin_email = t('validation.adminEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
newErrors.admin_email = t('validation.invalidEmailFormat');
}
} else if (formData.admin_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.admin_email)) {
// Still validate format if value is provided, even if optional
newErrors.admin_email = t('validation.invalidEmailFormat');
}
if (formData.require_password) {
if (!formData.password) {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
// Prevent simple numeric passwords like "123456"
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
}
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
newErrors.expires_in_days = t('validation.expirationRange');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
const feedbackSettings = formData.feedback_settings;
const payload = {
event_type: formData.event_type,
event_name: formData.event_name,
event_date: formData.event_date,
customer_name: formData.customer_name,
customer_email: formData.customer_email,
admin_email: formData.admin_email,
require_password: formData.require_password,
password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id,
feedback_enabled: feedbackSettings.feedback_enabled,
allow_ratings: feedbackSettings.allow_ratings,
allow_likes: feedbackSettings.allow_likes,
allow_comments: feedbackSettings.allow_comments,
allow_favorites: feedbackSettings.allow_favorites,
require_name_email: feedbackSettings.require_name_email,
moderate_comments: feedbackSettings.moderate_comments,
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
};
createMutation.mutate(payload);
};
const handleInputChange = (field: keyof FormData) => (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
setFormData({ ...formData, [field]: e.target.value });
setErrors({ ...errors, [field]: undefined });
};
const handleThemeChange = (newTheme: ThemeConfig) => {
setFormData(prev => ({
...prev,
theme_config: newTheme
}));
};
const handlePresetChange = (presetName: string) => {
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
setFormData(prev => ({
...prev,
theme_preset: presetName,
theme_config: preset.config
}));
}
};
const handlePasswordGenerated = (password: string) => {
setFormData(prev => ({
...prev,
password: password,
confirm_password: password
}));
// Clear password errors since we generated a valid one
if (errors.password || errors.confirm_password) {
setErrors(prev => ({
...prev,
password: undefined,
confirm_password: undefined
}));
}
};
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
leftIcon={<ArrowLeft className="w-4 h-4" />}
onClick={() => navigate('/admin/events')}
>
{t('common.back')}
</Button>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.create')}</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Event Details */}
<Card>
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Calendar className="w-5 h-5" />
{t('events.eventDetails')}
</h2>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.eventType')}
</label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{EVENT_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => setFormData({ ...formData, event_type: type.value })}
className={`p-4 rounded-lg border-2 transition-all ${
formData.event_type === type.value
? 'border-primary-600 bg-primary-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<div className="text-2xl mb-1">{type.emoji}</div>
<div className="text-sm font-medium">{t(type.labelKey)}</div>
</button>
))}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={t('events.eventName')}
placeholder={t('events.eventNamePlaceholder')}
value={formData.event_name}
onChange={handleInputChange('event_name')}
error={errors.event_name}
leftIcon={<Calendar className="w-5 h-5" />}
/>
<Input
type="date"
label={t('events.eventDate')}
value={formData.event_date}
onChange={handleInputChange('event_date')}
error={errors.event_date}
leftIcon={<Calendar className="w-5 h-5" />}
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.welcomeMessage')}
</label>
<WelcomeMessageEditor
value={formData.welcome_message}
onChange={(value) => setFormData(prev => ({ ...prev, welcome_message: value }))}
placeholder={t('events.welcomeMessagePlaceholder')}
rows={4}
/>
</div>
</div>
</Card>
{/* Theme Selection */}
<Card>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('events.themeAndStyle')}
</h2>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowThemeCustomizer(!showThemeCustomizer)}
leftIcon={showThemeCustomizer ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
>
{showThemeCustomizer ? t('common.hide') : t('common.customize')}
</Button>
</div>
{/* Quick Theme Preview */}
{!showThemeCustomizer && (
<div className="p-4 rounded-lg border border-neutral-200"
style={{
backgroundColor: formData.theme_config.backgroundColor,
color: formData.theme_config.textColor
}}
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold" style={{ fontFamily: formData.theme_config.fontFamily }}>
{GALLERY_THEME_PRESETS[formData.theme_preset]?.name || 'Custom Theme'}
</h3>
<div className="flex gap-2">
<div
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: formData.theme_config.primaryColor }}
/>
<div
className="w-6 h-6 rounded-full border-2 border-white shadow-sm"
style={{ backgroundColor: formData.theme_config.accentColor }}
/>
</div>
</div>
<p className="text-sm opacity-80">
Gallery Layout: <span className="font-medium capitalize">{formData.theme_config.galleryLayout || 'grid'}</span>
</p>
</div>
)}
{/* Theme Customizer */}
{showThemeCustomizer && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Theme Customizer */}
<ThemeCustomizerEnhanced
value={formData.theme_config}
onChange={handleThemeChange}
presetName={formData.theme_preset}
onPresetChange={handlePresetChange}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
/>
{/* Gallery Preview */}
<div className="lg:sticky lg:top-4 lg:h-fit">
<GalleryPreview
theme={formData.theme_config}
className="shadow-lg"
/>
</div>
</div>
</div>
)}
</div>
</Card>
{/* Access & Security */}
<Card>
<div className="p-6 space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 flex items-center gap-2">
<Lock className="w-5 h-5" />
{t('events.accessAndSecurity')}
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={requireCustomerName ? t('events.hostName') : `${t('events.hostName')} (${t('common.optional')})`}
placeholder={t('events.hostNamePlaceholder')}
value={formData.customer_name}
onChange={handleInputChange('customer_name')}
error={errors.customer_name}
leftIcon={<Calendar className="w-5 h-5" />}
/>
<Input
type="email"
label={requireCustomerEmail ? t('events.hostEmail') : `${t('events.hostEmail')} (${t('common.optional')})`}
placeholder={t('events.hostEmailPlaceholder')}
value={formData.customer_email}
onChange={handleInputChange('customer_email')}
error={errors.customer_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
</div>
<Input
type="email"
label={requireAdminEmail ? t('events.adminEmail') : `${t('events.adminEmail')} (${t('common.optional')})`}
placeholder={t('events.adminEmailPlaceholder')}
value={formData.admin_email}
onChange={handleInputChange('admin_email')}
error={errors.admin_email}
leftIcon={<Mail className="w-5 h-5" />}
/>
</div>
<div className="space-y-3">
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
checked={formData.require_password}
onChange={(e) => {
const checked = e.target.checked;
setFormData(prev => ({
...prev,
require_password: checked,
password: checked ? prev.password : '',
confirm_password: checked ? prev.confirm_password : '',
}));
if (!checked) {
setErrors(prev => ({ ...prev, password: undefined, confirm_password: undefined }));
}
}}
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('events.requirePasswordToggle')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
</p>
</div>
</label>
{!formData.require_password && (
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
</div>
)}
</div>
{formData.require_password && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.galleryPassword')}
placeholder={t('events.passwordPlaceholder')}
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
{/* Password Generator */}
<div className="mt-2">
<PasswordGenerator
eventName={formData.event_name}
eventDate={formData.event_date}
eventType={formData.event_type}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity="moderate"
className="w-full"
/>
</div>
</div>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.confirmPassword')}
placeholder={t('events.confirmPasswordPlaceholder')}
value={formData.confirm_password}
onChange={handleInputChange('confirm_password')}
error={errors.confirm_password}
leftIcon={<Lock className="w-5 h-5" />}
/>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.galleryExpiration')}
</label>
<div className="flex items-center gap-2">
<div className="w-32">
<Input
type="number"
value={formData.expires_in_days}
onChange={handleInputChange('expires_in_days')}
error={errors.expires_in_days}
min={1}
max={365}
leftIcon={<Clock className="w-5 h-5" />}
/>
</div>
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
</div>
{formData.event_date && (
<p className="mt-2 text-sm text-neutral-500">
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
</p>
)}
</div>
{/* User Upload Settings */}
<div className="pt-4 border-t border-neutral-200">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={formData.allow_user_uploads}
onChange={(e) => setFormData({ ...formData, allow_user_uploads: e.target.checked })}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700">
{t('events.allowUserUploads')}
</span>
<p className="text-xs text-neutral-500 mt-0.5">
{t('events.allowUserUploadsDescription')}
</p>
</div>
</label>
{formData.allow_user_uploads && categories && categories.length > 0 && (
<div className="mt-4 ml-7">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.uploadCategory')}
</label>
<select
value={formData.upload_category_id || ''}
onChange={(e) => setFormData({
...formData,
upload_category_id: e.target.value ? Number(e.target.value) : null
})}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="">{t('events.selectCategory')}</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
<p className="mt-1 text-xs text-neutral-500">
{t('events.uploadCategoryHelp')}
</p>
</div>
)}
</div>
</div>
</Card>
{/* Feedback Settings */}
<FeedbackSettings
settings={formData.feedback_settings}
onChange={(settings) => setFormData(prev => ({ ...prev, feedback_settings: settings }))}
/>
{/* Form Actions */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => navigate('/admin/events')}
>
{t('common.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={createMutation.isPending}
disabled={createMutation.isPending}
>
{t('events.createEvent')}
</Button>
</div>
</form>
</div>
);
};
+1 -29
View File
@@ -26,39 +26,11 @@ import { adminService } from '../../services/admin.service';
import { authService } from '../../services/auth.service';
import { useTranslation } from 'react-i18next';
import { useAdminAuth } from '../../contexts';
import { toBoolean, toNumber } from '../../utils/parsers';
const BYTES_PER_GB = 1024 * 1024 * 1024;
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
const toBoolean = (value: unknown, defaultValue = false): boolean => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
if (Number.isNaN(value)) return defaultValue;
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.toLowerCase().trim();
if (normalized === 'true' || normalized === '1') return true;
if (normalized === 'false' || normalized === '0') return false;
if (normalized === '') return defaultValue;
return Boolean(normalized);
}
return defaultValue;
};
const toNumber = (value: unknown, defaultValue: number): number => {
if (value === undefined || value === null || value === '') {
return defaultValue;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : defaultValue;
};
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'events' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation' | 'styling'>('general');
const queryClient = useQueryClient();
+1 -1
View File
@@ -1,7 +1,7 @@
export { AdminLoginPage } from './AdminLoginPage';
export { AdminDashboard } from './AdminDashboard';
export { EventsListPage } from './EventsListPage';
export { CreateEventPageEnhanced } from './CreateEventPageEnhanced';
export { CreateEventPage } from './CreateEventPage';
export { EventDetailsPage } from './EventDetailsPage';
export { EmailConfigPage } from './EmailConfigPage';
export { ArchivesPage } from './ArchivesPage';
+166
View File
@@ -0,0 +1,166 @@
/**
* Shared Parser Utilities for Frontend
* Pure functions for parsing and transforming input values
*
* @module utils/parsers
*/
/**
* Parse any input value to boolean with configurable default
* Handles: boolean, number, string representations
*
* @param value - Input value to parse
* @param defaultValue - Default if value is undefined/null
* @returns boolean result
*
* @example
* toBoolean(true) // true
* toBoolean('false') // false
* toBoolean('1') // true
* toBoolean(0) // false
* toBoolean(undefined, false) // false
*/
export const toBoolean = (value: unknown, defaultValue = false): boolean => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
if (Number.isNaN(value)) return defaultValue;
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.toLowerCase().trim();
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
if (['false', '0', 'no', 'off', ''].includes(normalized)) return false;
}
return defaultValue;
};
/**
* Parse numeric input with validation and optional bounds
*
* @param value - Input value to parse
* @param defaultValue - Default if invalid
* @param options - Bounds options
* @returns number result
*
* @example
* toNumber('42', 0) // 42
* toNumber('abc', 10) // 10
* toNumber(5, 0, { min: 10 }) // 10
*/
export const toNumber = (
value: unknown,
defaultValue: number,
options?: { min?: number; max?: number }
): number => {
if (value === undefined || value === null || value === '') {
return defaultValue;
}
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return defaultValue;
}
let result = parsed;
if (options?.min !== undefined && result < options.min) result = options.min;
if (options?.max !== undefined && result > options.max) result = options.max;
return result;
};
/**
* Parse string input with trimming and null handling
*
* @param value - Input value
* @param defaultValue - Default if empty
* @returns string or null
*
* @example
* toString(' hello ') // 'hello'
* toString('') // null
* toString(null, 'default') // 'default'
*/
export const toString = (value: unknown, defaultValue: string | null = null): string | null => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || defaultValue;
}
return String(value);
};
/**
* Parse JSON string safely
*
* @param value - JSON string or already parsed value
* @param defaultValue - Default if parsing fails
* @returns parsed value or default
*/
export const parseJson = <T>(value: unknown, defaultValue: T): T => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value !== 'string') {
return value as T;
}
try {
return JSON.parse(value) as T;
} catch {
return defaultValue;
}
};
/**
* Parse date input to Date object
*
* @param value - Date string, Date object, or timestamp
* @returns Date object or null if invalid
*/
export const toDate = (value: unknown): Date | null => {
if (value === undefined || value === null || value === '') {
return null;
}
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value;
}
const date = new Date(value as string | number);
return isNaN(date.getTime()) ? null : date;
};
/**
* Parse array input (handles JSON strings and arrays)
*
* @param value - Array or JSON string
* @param defaultValue - Default if invalid
* @returns array result
*/
export const toArray = <T>(value: unknown, defaultValue: T[] = []): T[] => {
if (value === undefined || value === null) {
return defaultValue;
}
if (Array.isArray(value)) {
return value as T[];
}
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : defaultValue;
} catch {
// Try comma-separated for string arrays
return value.split(',').map(s => s.trim()).filter(Boolean) as T[];
}
}
return defaultValue;
};
// Re-export with alternative names for backwards compatibility
export const parseBooleanInput = toBoolean;
export const parseNumberInput = toNumber;
export const parseStringInput = toString;