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:
Generated
+6
-23
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,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,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,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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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,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') {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
Generated
+179
-44
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user