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",
|
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.940.0.tgz",
|
||||||
"integrity": "sha512-Wi4qnBT6shRRMXuuTgjMFTU5mu2KFWisgcigEMPptjPGUtJvBVi4PTGgS64qsLoUk/obqDAyOBOfEtRZ2ddC2w==",
|
"integrity": "sha512-Wi4qnBT6shRRMXuuTgjMFTU5mu2KFWisgcigEMPptjPGUtJvBVi4PTGgS64qsLoUk/obqDAyOBOfEtRZ2ddC2w==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-crypto/sha1-browser": "5.2.0",
|
"@aws-crypto/sha1-browser": "5.2.0",
|
||||||
"@aws-crypto/sha256-browser": "5.2.0",
|
"@aws-crypto/sha256-browser": "5.2.0",
|
||||||
@@ -1025,6 +1026,7 @@
|
|||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -3831,6 +3833,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4431,6 +4434,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.25",
|
"baseline-browser-mapping": "^2.8.25",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001754",
|
||||||
@@ -5396,29 +5400,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/encoding": {
|
|
||||||
"version": "0.1.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
|
||||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"iconv-lite": "^0.6.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/encoding/node_modules/iconv-lite": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/end-of-stream": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -5546,6 +5527,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -6703,6 +6685,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ const {
|
|||||||
} = require('./src/utils/tokenUtils');
|
} = require('./src/utils/tokenUtils');
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
const authRoutes = require('./src/routes/auth-enhanced');
|
const authRoutes = require('./src/routes/auth');
|
||||||
const eventRoutes = require('./src/routes/events');
|
const eventRoutes = require('./src/routes/events');
|
||||||
const galleryRoutes = require('./src/routes/gallery');
|
const galleryRoutes = require('./src/routes/gallery');
|
||||||
const adminRoutes = require('./src/routes/admin');
|
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 jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||||
const logger = require('../utils/logger');
|
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) {
|
async function adminAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const token = getAdminTokenFromRequest(req);
|
const token = getAdminTokenFromRequest(req);
|
||||||
if (!token) {
|
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' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||||
try {
|
issuer: 'picpeak-auth',
|
||||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
complete: true
|
||||||
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 = decoded.payload;
|
||||||
if (jwtError.name === 'TokenExpiredError') {
|
} catch (err) {
|
||||||
return res.status(401).json({ error: 'Token expired' });
|
if (err.name === 'TokenExpiredError') {
|
||||||
|
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
|
||||||
}
|
}
|
||||||
return res.status(401).json({ error: 'Invalid token' });
|
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) {
|
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' });
|
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();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
logger.error('Auth middleware error:', error);
|
||||||
req.headers['x-real-ip'] ||
|
res.status(401).json({ error: 'Authentication failed' });
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
adminAuth: (_req, _res, next) => {
|
||||||
_req.admin = { id: 1, username: 'admin' };
|
_req.admin = { id: 1, username: 'admin' };
|
||||||
next();
|
next();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jest.mock('../../database/db', () => {
|
|||||||
return { db: dbMock };
|
return { db: dbMock };
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('../../middleware/auth-enhanced-v2', () => ({
|
jest.mock('../../middleware/auth', () => ({
|
||||||
adminAuth: (_req, _res, next) => next(),
|
adminAuth: (_req, _res, next) => next(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ const path = require('path');
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
const AdmZip = require('adm-zip');
|
const AdmZip = require('adm-zip');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { endSession } = require('../middleware/sessionTimeout');
|
const { endSession } = require('../middleware/sessionTimeout');
|
||||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all CMS pages
|
// Get all CMS pages
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get all global categories
|
// Get all global categories
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get email configuration
|
// Get email configuration
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const eventRenameService = require('../services/eventRenameService');
|
const eventRenameService = require('../services/eventRenameService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const { body, query, validationResult } = require('express-validator');
|
const { body, query, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -15,6 +15,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
|
|||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
|
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
||||||
|
|
||||||
// Helper to get event field requirements from settings
|
// Helper to get event field requirements from settings
|
||||||
const getEventFieldRequirements = async () => {
|
const getEventFieldRequirements = async () => {
|
||||||
@@ -58,43 +59,9 @@ const getEventFieldRequirements = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseBooleanInput = (value, defaultValue = true) => {
|
// Use parseStringInput from shared parsers for customer data extraction
|
||||||
if (value === undefined || value === null) {
|
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||||
return defaultValue;
|
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||||
}
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapEventForApi = (event) => {
|
const mapEventForApi = (event) => {
|
||||||
if (!event || typeof event !== 'object') {
|
if (!event || typeof event !== 'object') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const feedbackService = require('../services/feedbackService');
|
const feedbackService = require('../services/feedbackService');
|
||||||
const feedbackModeration = require('../services/feedbackModeration');
|
const feedbackModeration = require('../services/feedbackModeration');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get notifications (unread activity logs)
|
// Get notifications (unread activity logs)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { db, withRetry } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
|||||||
@@ -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 { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { verifyRecaptcha } = require('../services/recaptcha');
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
// Admin login
|
// Admin login with enhanced security
|
||||||
router.post('/admin/login', [
|
router.post('/admin/login', [
|
||||||
body('username').notEmpty(),
|
body('username').notEmpty().trim(),
|
||||||
body('password').notEmpty()
|
body('password').notEmpty()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -19,30 +43,71 @@ router.post('/admin/login', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { username, password, recaptchaToken } = req.body;
|
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
|
// Verify reCAPTCHA
|
||||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||||
if (!recaptchaValid) {
|
if (!recaptchaValid) {
|
||||||
|
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
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')
|
const admin = await db('admin_users')
|
||||||
.where({ username })
|
.where({ username })
|
||||||
.orWhere({ email: username })
|
.orWhere({ email: username })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
// Use generic error to prevent user enumeration
|
||||||
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
await trackFailedAttempt(username, ipAddress, userAgent);
|
||||||
|
return res.status(401).json({ error: getGenericAuthError() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!admin.is_active) {
|
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
|
// Successful login
|
||||||
await db('admin_users').where('id', admin.id).update({ last_login: new Date() });
|
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({
|
res.json({
|
||||||
token,
|
token,
|
||||||
@@ -54,14 +119,57 @@ router.post('/admin/login', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Login error:', error);
|
||||||
res.status(500).json({ error: 'Login failed' });
|
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', [
|
router.post('/gallery/verify', [
|
||||||
body('slug').notEmpty(),
|
body('slug').notEmpty().trim(),
|
||||||
body('password').notEmpty()
|
body('password').optional().isString()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -70,67 +178,333 @@ router.post('/gallery/verify', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { slug, password, recaptchaToken } = req.body;
|
const { slug, password, recaptchaToken } = req.body;
|
||||||
|
const ipAddress = getClientIp(req);
|
||||||
// Verify reCAPTCHA - temporarily disabled for testing
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
// const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
const event = await db('events')
|
||||||
// if (!recaptchaValid) {
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
// return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
.first();
|
||||||
// }
|
|
||||||
|
|
||||||
const event = await db('events').where({ slug: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).select('*').first();
|
|
||||||
if (!event) {
|
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);
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
if (!validPassword) {
|
|
||||||
|
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({
|
await db('access_logs').insert({
|
||||||
event_id: event.id,
|
event_id: event.id,
|
||||||
ip_address: req.ip,
|
ip_address: ipAddress,
|
||||||
user_agent: req.headers['user-agent'],
|
user_agent: userAgent,
|
||||||
action: 'login_fail'
|
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({
|
const token = jwt.sign({
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventSlug: event.slug,
|
eventSlug: event.slug,
|
||||||
type: 'gallery'
|
type: 'gallery',
|
||||||
}, process.env.JWT_SECRET, { expiresIn: '24h' });
|
ip: ipAddress,
|
||||||
|
loginTime: Date.now()
|
||||||
const responseEvent = {
|
}, process.env.JWT_SECRET, {
|
||||||
id: event.id,
|
expiresIn: '24h',
|
||||||
event_name: event.event_name,
|
issuer: 'picpeak-auth'
|
||||||
event_type: event.event_type,
|
});
|
||||||
event_date: event.event_date,
|
|
||||||
welcome_message: event.welcome_message,
|
setGalleryAuthCookies(res, token, event.slug);
|
||||||
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));
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
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) {
|
} catch (error) {
|
||||||
|
logger.error('Gallery verification error:', error);
|
||||||
res.status(500).json({ error: 'Verification failed' });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -5,49 +5,16 @@ const crypto = require('crypto');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
|
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||||
|
|
||||||
const parseBooleanInput = (value, defaultValue = true) => {
|
// Use parseStringInput from shared parsers for customer data extraction
|
||||||
if (value === undefined || value === null) {
|
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||||
return defaultValue;
|
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
|
||||||
}
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapEventForApi = (event) => {
|
const mapEventForApi = (event) => {
|
||||||
if (!event || typeof event !== 'object') {
|
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==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -488,6 +489,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
@@ -511,6 +513,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -1626,6 +1629,44 @@
|
|||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@testing-library/jest-dom": {
|
||||||
"version": "6.9.1",
|
"version": "6.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"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",
|
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz",
|
||||||
"integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==",
|
"integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"url": "https://github.com/sponsors/ueberdosis"
|
||||||
@@ -2067,6 +2110,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-changeset": "^2.3.0",
|
"prosemirror-changeset": "^2.3.0",
|
||||||
"prosemirror-collab": "^1.3.1",
|
"prosemirror-collab": "^1.3.1",
|
||||||
@@ -2148,6 +2192,13 @@
|
|||||||
"url": "https://github.com/sponsors/ueberdosis"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
||||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -2293,6 +2345,7 @@
|
|||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
@@ -2370,6 +2423,7 @@
|
|||||||
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.48.0",
|
"@typescript-eslint/scope-manager": "8.48.0",
|
||||||
"@typescript-eslint/types": "8.48.0",
|
"@typescript-eslint/types": "8.48.0",
|
||||||
@@ -2736,6 +2790,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2780,6 +2835,16 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"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": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
@@ -2817,6 +2882,19 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -2979,6 +3057,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.25",
|
"baseline-browser-mapping": "^2.8.25",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001754",
|
||||||
@@ -3411,6 +3490,16 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -3596,6 +3685,7 @@
|
|||||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -4170,6 +4260,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
|
||||||
"integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==",
|
"integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.0.0"
|
"node": ">=12.0.0"
|
||||||
}
|
}
|
||||||
@@ -4252,6 +4343,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
@@ -4424,6 +4516,7 @@
|
|||||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "bin/jiti.js"
|
"jiti": "bin/jiti.js"
|
||||||
}
|
}
|
||||||
@@ -4684,6 +4777,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz",
|
||||||
"integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==",
|
"integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/hast": "^2.0.0",
|
"@types/hast": "^2.0.0",
|
||||||
"fault": "^2.0.0",
|
"fault": "^2.0.0",
|
||||||
@@ -4713,6 +4807,16 @@
|
|||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -4779,6 +4883,19 @@
|
|||||||
"node": ">=8.6"
|
"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": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
@@ -5088,13 +5205,14 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
@@ -5140,6 +5258,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -5293,6 +5412,41 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"orderedmap": "^2.0.0"
|
"orderedmap": "^2.0.0"
|
||||||
}
|
}
|
||||||
@@ -5445,6 +5600,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.0.0",
|
"prosemirror-model": "^1.0.0",
|
||||||
"prosemirror-transform": "^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",
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz",
|
||||||
"integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
|
"integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"prosemirror-model": "^1.20.0",
|
"prosemirror-model": "^1.20.0",
|
||||||
"prosemirror-state": "^1.0.0",
|
"prosemirror-state": "^1.0.0",
|
||||||
@@ -5550,6 +5707,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -5588,6 +5746,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -5743,6 +5902,19 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -6175,19 +6347,6 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"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": {
|
"node_modules/tinypool": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||||
@@ -6316,8 +6475,9 @@
|
|||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6419,6 +6579,7 @@
|
|||||||
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
|
"integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -6511,19 +6672,6 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/vitest": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
"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": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
AdminLoginPage,
|
AdminLoginPage,
|
||||||
AdminDashboard,
|
AdminDashboard,
|
||||||
EventsListPage,
|
EventsListPage,
|
||||||
CreateEventPageEnhanced as CreateEventPage,
|
CreateEventPage,
|
||||||
EventDetailsPage,
|
EventDetailsPage,
|
||||||
EventFeedbackPage,
|
EventFeedbackPage,
|
||||||
EmailConfigPage,
|
EmailConfigPage,
|
||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
AnalyticsPage,
|
AnalyticsPage,
|
||||||
BrandingPage,
|
BrandingPage,
|
||||||
SettingsPage,
|
SettingsPage,
|
||||||
BackupManagement
|
BackupManagement,
|
||||||
|
CMSPage
|
||||||
} from './pages/admin';
|
} from './pages/admin';
|
||||||
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
|
||||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
||||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||||
@@ -127,7 +127,7 @@ function App() {
|
|||||||
<Route path="branding" element={<BrandingPage />} />
|
<Route path="branding" element={<BrandingPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="backup" element={<BackupManagement />} />
|
<Route path="backup" element={<BackupManagement />} />
|
||||||
<Route path="cms" element={<CMSPageEnhanced />} />
|
<Route path="cms" element={<CMSPage />} />
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'react-toastify';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
import { Button, Card, Input, Loading } from '../../components/common';
|
import { Button, Card, Input, Loading } from '../../components/common';
|
||||||
@@ -17,6 +18,9 @@ export const CMSPage: React.FC = () => {
|
|||||||
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
||||||
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
||||||
const [editForm, setEditForm] = useState<Partial<CMSPageType>>({});
|
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 [publicSiteEnabled, setPublicSiteEnabled] = useState(false);
|
||||||
const [publicSiteHtml, setPublicSiteHtml] = useState('');
|
const [publicSiteHtml, setPublicSiteHtml] = useState('');
|
||||||
const [publicSiteCss, setPublicSiteCss] = useState('');
|
const [publicSiteCss, setPublicSiteCss] = useState('');
|
||||||
@@ -39,63 +43,25 @@ export const CMSPage: React.FC = () => {
|
|||||||
queryFn: () => settingsService.getPublicSiteDefaults(),
|
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
|
// Update page mutation
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
mutationFn: ({ slug, data }: { slug: string; data: Partial<CMSPageType> }) =>
|
||||||
cmsService.updatePage(slug, data),
|
cmsService.updatePage(slug, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
|
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: () => {
|
onError: () => {
|
||||||
|
setIsAutoSaving(false);
|
||||||
toast.error(t('toast.saveError'));
|
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({
|
const publicSiteSaveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const trimmedHtml = publicSiteHtml.trim();
|
const trimmedHtml = publicSiteHtml.trim();
|
||||||
@@ -140,19 +106,95 @@ export const CMSPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t('settings.publicSite.resetError'));
|
toast.error(t('settings.publicSite.resetError'));
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
// Auto-save functionality
|
||||||
return (
|
const autoSave = useCallback(
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
debounce(() => {
|
||||||
<Loading size="lg" text={t('cms.loadingPages')} />
|
if (hasUnsavedChanges && !updateMutation.isPending) {
|
||||||
</div>
|
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 currentPage = pages?.find(p => p.slug === selectedPage);
|
||||||
|
|
||||||
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
|
const publicSiteSanitizedHtml = useMemo(() => DOMPurify.sanitize(publicSiteHtml || '', {
|
||||||
ALLOWED_TAGS: [
|
ALLOWED_TAGS: [
|
||||||
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
|
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div', 'em',
|
||||||
@@ -204,15 +246,9 @@ export const CMSPage: React.FC = () => {
|
|||||||
company_name: branding.companyName || '',
|
company_name: branding.companyName || '',
|
||||||
company_tagline: branding.companyTagline || '',
|
company_tagline: branding.companyTagline || '',
|
||||||
support_email: branding.supportEmail || '',
|
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,
|
return html.replace(/\{\{\s*(company_name|company_tagline|support_email)\s*\}\}/gi, (_, key: string) => tokens[key] || '');
|
||||||
(_, key: string) => tokens[key] || '');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const publicSitePreview = useMemo(() => {
|
const publicSitePreview = useMemo(() => {
|
||||||
@@ -278,6 +314,14 @@ export const CMSPage: React.FC = () => {
|
|||||||
|
|
||||||
const publicSiteLoading = isLoadingAdminSettings || isLoadingPublicDefaults;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -423,18 +467,28 @@ export const CMSPage: React.FC = () => {
|
|||||||
{pages?.map((page) => (
|
{pages?.map((page) => (
|
||||||
<button
|
<button
|
||||||
key={page.slug}
|
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 ${
|
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${
|
||||||
selectedPage === page.slug
|
selectedPage === page.slug
|
||||||
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
? 'bg-primary-100 text-primary-700 border border-primary-300'
|
||||||
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
: 'bg-white border border-neutral-200 hover:bg-neutral-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FileText className="w-5 h-5" />
|
<FileText className="w-5 h-5 flex-shrink-0" />
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium">{t(`legal.${page.slug}`)}</p>
|
<p className="font-medium truncate">{t(`legal.${page.slug}`)}</p>
|
||||||
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
<p className="text-sm text-neutral-500">/{page.slug}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedPage === page.slug && hasUnsavedChanges && (
|
||||||
|
<div className="w-2 h-2 bg-yellow-500 rounded-full flex-shrink-0" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -463,6 +517,32 @@ export const CMSPage: React.FC = () => {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Editor */}
|
{/* Editor */}
|
||||||
@@ -483,7 +563,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🇬🇧 English
|
English
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingLang('de')}
|
onClick={() => setEditingLang('de')}
|
||||||
@@ -493,7 +573,7 @@ export const CMSPage: React.FC = () => {
|
|||||||
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
🇩🇪 Deutsch
|
Deutsch
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 { authService } from '../../services/auth.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAdminAuth } from '../../contexts';
|
import { useAdminAuth } from '../../contexts';
|
||||||
|
import { toBoolean, toNumber } from '../../utils/parsers';
|
||||||
|
|
||||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
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 = () => {
|
export const SettingsPage: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'general' | 'events' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation' | 'styling'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'events' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation' | 'styling'>('general');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export { AdminLoginPage } from './AdminLoginPage';
|
export { AdminLoginPage } from './AdminLoginPage';
|
||||||
export { AdminDashboard } from './AdminDashboard';
|
export { AdminDashboard } from './AdminDashboard';
|
||||||
export { EventsListPage } from './EventsListPage';
|
export { EventsListPage } from './EventsListPage';
|
||||||
export { CreateEventPageEnhanced } from './CreateEventPageEnhanced';
|
export { CreateEventPage } from './CreateEventPage';
|
||||||
export { EventDetailsPage } from './EventDetailsPage';
|
export { EventDetailsPage } from './EventDetailsPage';
|
||||||
export { EmailConfigPage } from './EmailConfigPage';
|
export { EmailConfigPage } from './EmailConfigPage';
|
||||||
export { ArchivesPage } from './ArchivesPage';
|
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