feat: support per-gallery password toggle
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
exports.up = async function (knex) {
|
||||||
|
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||||
|
if (!hasColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.boolean('require_password').notNullable().defaultTo(true);
|
||||||
|
});
|
||||||
|
await knex('events').update({ require_password: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||||
|
if (hasColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('require_password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -82,6 +82,7 @@ async function initializeDatabase() {
|
|||||||
table.boolean('watermark_downloads').defaultTo(false);
|
table.boolean('watermark_downloads').defaultTo(false);
|
||||||
table.text('watermark_text');
|
table.text('watermark_text');
|
||||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||||
|
table.boolean('require_password').defaultTo(true);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Check if color_theme needs to be updated to TEXT type
|
// Check if color_theme needs to be updated to TEXT type
|
||||||
@@ -116,7 +117,8 @@ async function initializeDatabase() {
|
|||||||
disable_right_click BOOLEAN DEFAULT 0,
|
disable_right_click BOOLEAN DEFAULT 0,
|
||||||
watermark_downloads BOOLEAN DEFAULT 0,
|
watermark_downloads BOOLEAN DEFAULT 0,
|
||||||
watermark_text TEXT,
|
watermark_text TEXT,
|
||||||
hero_photo_id INTEGER
|
hero_photo_id INTEGER,
|
||||||
|
require_password BOOLEAN DEFAULT 1
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -138,6 +140,8 @@ async function initializeDatabase() {
|
|||||||
return 'watermark_text';
|
return 'watermark_text';
|
||||||
case 'hero_photo_id':
|
case 'hero_photo_id':
|
||||||
return 'hero_photo_id';
|
return 'hero_photo_id';
|
||||||
|
case 'require_password':
|
||||||
|
return 'COALESCE(require_password, 1) as require_password';
|
||||||
default:
|
default:
|
||||||
return col;
|
return col;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,48 @@ const jwt = require('jsonwebtoken');
|
|||||||
const { db, withRetry } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
// Middleware to verify gallery access
|
// Middleware to verify gallery access
|
||||||
async function verifyGalleryAccess(req, res, next) {
|
async function verifyGalleryAccess(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||||
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
||||||
|
let event;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
if (!requestedSlug) {
|
||||||
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
event = await withRetry(async () => {
|
||||||
|
return await db('events')
|
||||||
|
.where({
|
||||||
|
slug: requestedSlug,
|
||||||
|
is_active: formatBoolean(true),
|
||||||
|
is_archived: formatBoolean(false)
|
||||||
|
})
|
||||||
|
.select('*')
|
||||||
|
.first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
if (!requiresPassword) {
|
||||||
|
req.event = event;
|
||||||
|
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
|
||||||
|
req.clientInfo = {
|
||||||
|
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||||
|
userAgent: req.get('User-Agent') || 'unknown',
|
||||||
|
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,10 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
|
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||||
|
|
||||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||||
let event;
|
|
||||||
if (requestedSlug) {
|
if (requestedSlug) {
|
||||||
// Verify by slug and ensure it matches the token's event
|
// Verify by slug and ensure it matches the token's event
|
||||||
event = await withRetry(async () => {
|
event = await withRetry(async () => {
|
||||||
@@ -62,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId);
|
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
|
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||||
req.event = event;
|
req.event = event;
|
||||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||||
|
|
||||||
@@ -78,10 +112,10 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[verifyGalleryAccess] Access granted for event:', event.id);
|
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying gallery access:', error);
|
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
||||||
res.status(401).json({ error: 'Invalid token' });
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ 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 { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
async function photoAuth(req, res, next) {
|
async function photoAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
// Extract event slug from the path
|
// Extract event slug from the path
|
||||||
let eventSlug;
|
let eventSlug;
|
||||||
|
|
||||||
console.log('PhotoAuth middleware - path:', req.path);
|
|
||||||
|
|
||||||
// For thumbnails, we need to parse the filename to get the event info
|
// For thumbnails, we need to parse the filename to get the event info
|
||||||
if (req.path.startsWith('/thumb_')) {
|
if (req.path.startsWith('/thumb_')) {
|
||||||
// For now, we'll rely on JWT token for thumbnail access
|
// For now, we'll rely on JWT token for thumbnail access
|
||||||
@@ -80,29 +79,36 @@ async function photoAuth(req, res, next) {
|
|||||||
// For both thumbnails and photos with admin token, allow access
|
// For both thumbnails and photos with admin token, allow access
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, fall through to password check
|
// Token invalid, fall through to password check
|
||||||
console.error('JWT verification failed:', err.message);
|
logger.warn('JWT verification failed in photoAuth', { error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for password header (legacy support)
|
// Check for password header (legacy support)
|
||||||
const password = req.headers['x-gallery-password'];
|
const password = req.headers['x-gallery-password'];
|
||||||
|
|
||||||
if (!password && !tokenFromRequest) {
|
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||||
if (!eventSlug && !password) {
|
if (!eventSlug && !password && !tokenFromRequest) {
|
||||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found' });
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
|
if (!requiresPassword) {
|
||||||
|
req.event = event;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password && !tokenFromRequest) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
@@ -122,7 +128,7 @@ async function photoAuth(req, res, next) {
|
|||||||
req.event = event;
|
req.event = event;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Photo auth error:', error);
|
logger.error('Photo auth error', { error: error.message, stack: error.stack });
|
||||||
res.status(500).json({ error: 'Authentication error' });
|
res.status(500).json({ error: 'Authentication error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ const { queueEmail } = require('../services/emailProcessor');
|
|||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
// formatDate import removed - dates are formatted by email processor
|
// formatDate import removed - dates are formatted by email processor
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const parseBooleanInput = (value, defaultValue = true) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
@@ -21,7 +44,30 @@ router.post('/', adminAuth, [
|
|||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('host_email').isEmail().normalizeEmail(),
|
body('host_email').isEmail().normalizeEmail(),
|
||||||
body('admin_email').isEmail().normalizeEmail(),
|
body('admin_email').isEmail().normalizeEmail(),
|
||||||
body('password').isLength({ min: 6 }),
|
body('require_password').optional().isBoolean(),
|
||||||
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
|
const input = req.body.require_password;
|
||||||
|
const normalizeBoolean = (val, defaultValue = true) => {
|
||||||
|
if (val === undefined || val === null) return defaultValue;
|
||||||
|
if (typeof val === 'boolean') return val;
|
||||||
|
if (typeof val === 'number') return val !== 0;
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
const normalized = val.trim().toLowerCase();
|
||||||
|
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||||
|
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requirePassword = normalizeBoolean(input, true);
|
||||||
|
if (!requirePassword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||||
|
throw new Error('Password must be at least 6 characters long');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||||
body('welcome_message').optional().trim(),
|
body('welcome_message').optional().trim(),
|
||||||
body('color_theme').optional().trim(),
|
body('color_theme').optional().trim(),
|
||||||
@@ -34,7 +80,7 @@ router.post('/', adminAuth, [
|
|||||||
body('watermark_text').optional().trim()
|
body('watermark_text').optional().trim()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('Create event request body:', req.body);
|
logger.debug('Create event request body', { body: req.body });
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
console.error('Validation errors:', errors.array());
|
console.error('Validation errors:', errors.array());
|
||||||
@@ -58,6 +104,7 @@ router.post('/', adminAuth, [
|
|||||||
disable_right_click = false,
|
disable_right_click = false,
|
||||||
watermark_downloads = false,
|
watermark_downloads = false,
|
||||||
watermark_text = null,
|
watermark_text = null,
|
||||||
|
require_password: requirePasswordInput = true,
|
||||||
// Feedback settings
|
// Feedback settings
|
||||||
feedback_enabled = false,
|
feedback_enabled = false,
|
||||||
allow_ratings = true,
|
allow_ratings = true,
|
||||||
@@ -69,12 +116,15 @@ router.post('/', adminAuth, [
|
|||||||
show_feedback_to_guests = true
|
show_feedback_to_guests = true
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('Download control values:', {
|
logger.debug('Download control values', {
|
||||||
allow_downloads,
|
allow_downloads,
|
||||||
disable_right_click,
|
disable_right_click,
|
||||||
watermark_downloads,
|
watermark_downloads,
|
||||||
watermark_text,
|
watermark_text,
|
||||||
|
require_password: requirePassword,
|
||||||
types: {
|
types: {
|
||||||
allow_downloads: typeof allow_downloads,
|
allow_downloads: typeof allow_downloads,
|
||||||
disable_right_click: typeof disable_right_click,
|
disable_right_click: typeof disable_right_click,
|
||||||
@@ -82,18 +132,24 @@ router.post('/', adminAuth, [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate password strength
|
let passwordValidation = null;
|
||||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
let galleryPassword = password;
|
||||||
eventName: event_name
|
|
||||||
});
|
if (requirePassword) {
|
||||||
|
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
if (!passwordValidation.valid) {
|
eventName: event_name
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Password does not meet security requirements',
|
|
||||||
details: passwordValidation.errors,
|
|
||||||
score: passwordValidation.score,
|
|
||||||
feedback: passwordValidation.feedback
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Password does not meet security requirements',
|
||||||
|
details: passwordValidation.errors,
|
||||||
|
score: passwordValidation.score,
|
||||||
|
feedback: passwordValidation.feedback
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
galleryPassword = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug
|
||||||
@@ -113,10 +169,14 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Generate share link
|
// Generate share link
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||||
|
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||||
|
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||||
|
|
||||||
// Hash password with configurable rounds
|
// Hash password with configurable rounds (random placeholder when not required)
|
||||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
const password_hash = requirePassword
|
||||||
|
? await bcrypt.hash(password, getBcryptRounds())
|
||||||
|
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
|
|
||||||
// Calculate expiration date (days after event date)
|
// Calculate expiration date (days after event date)
|
||||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||||
@@ -155,7 +215,8 @@ router.post('/', adminAuth, [
|
|||||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||||
watermark_text
|
watermark_text,
|
||||||
|
require_password: formatBoolean(requirePassword)
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
@@ -180,7 +241,7 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await logActivity('event_created',
|
await logActivity('event_created',
|
||||||
{ event_type, expires_at },
|
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
|
||||||
eventId,
|
eventId,
|
||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
@@ -197,7 +258,7 @@ router.post('/', adminAuth, [
|
|||||||
event_name,
|
event_name,
|
||||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: shareLink,
|
gallery_link: shareLink,
|
||||||
gallery_password: password,
|
gallery_password: requirePassword ? password : 'No password required',
|
||||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
}),
|
}),
|
||||||
@@ -211,6 +272,7 @@ router.post('/', adminAuth, [
|
|||||||
slug,
|
slug,
|
||||||
event_name,
|
event_name,
|
||||||
event_type,
|
event_type,
|
||||||
|
require_password: requirePassword,
|
||||||
share_link: shareLink,
|
share_link: shareLink,
|
||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
@@ -398,19 +460,45 @@ router.put('/:id', adminAuth, [
|
|||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
body('watermark_text').optional().trim(),
|
body('watermark_text').optional().trim(),
|
||||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||||
body('external_path').optional({ nullable: true }).isString().trim()
|
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||||
|
body('require_password').optional().isBoolean(),
|
||||||
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||||
|
throw new Error('Password must be at least 6 characters long');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2));
|
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
|
||||||
console.log('Request body:', req.body);
|
|
||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = { ...req.body };
|
const updates = { ...req.body };
|
||||||
|
|
||||||
|
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||||
|
let requirePasswordUpdate;
|
||||||
|
if (hasRequirePasswordUpdate) {
|
||||||
|
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||||
|
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPasswordPlain;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||||
|
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||||
|
delete updates.password;
|
||||||
|
} else {
|
||||||
|
newPasswordPlain = updates.password;
|
||||||
|
delete updates.password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
||||||
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
||||||
}
|
}
|
||||||
@@ -429,7 +517,7 @@ router.put('/:id', adminAuth, [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the update request for debugging
|
// Log the update request for debugging
|
||||||
console.log('Update event request:', {
|
logger.debug('Update event request', {
|
||||||
id,
|
id,
|
||||||
updates,
|
updates,
|
||||||
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
||||||
@@ -444,6 +532,18 @@ router.put('/:id', adminAuth, [
|
|||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||||
|
|
||||||
|
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||||
|
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPasswordPlain) {
|
||||||
|
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||||
|
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||||
|
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
|
}
|
||||||
|
|
||||||
// Update event
|
// Update event
|
||||||
await db('events')
|
await db('events')
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ router.post('/logout', async (req, res) => {
|
|||||||
// Gallery password verification with enhanced security
|
// Gallery password verification with enhanced security
|
||||||
router.post('/gallery/verify', [
|
router.post('/gallery/verify', [
|
||||||
body('slug').notEmpty().trim(),
|
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);
|
||||||
@@ -232,53 +232,69 @@ router.post('/gallery/verify', [
|
|||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
// Check gallery-specific lockout
|
|
||||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify reCAPTCHA
|
|
||||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
|
||||||
if (!recaptchaValid) {
|
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
|
||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
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}`);
|
||||||
|
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) {
|
if (!event) {
|
||||||
// Don't reveal if gallery exists
|
// Don't reveal if gallery exists
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
if (requiresPassword) {
|
||||||
if (!validPassword) {
|
if (!password) {
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
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: ipAddress,
|
ip_address: ipAddress,
|
||||||
user_agent: userAgent,
|
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 gallery or password' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful access
|
|
||||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
|
||||||
|
|
||||||
// Log successful access
|
|
||||||
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
|
// Generate session token with additional security info
|
||||||
const token = jwt.sign({
|
const token = jwt.sign({
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
@@ -302,7 +318,8 @@ router.post('/gallery/verify', [
|
|||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
allow_user_uploads: event.allow_user_uploads,
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
upload_category_id: event.upload_category_id
|
upload_category_id: event.upload_category_id,
|
||||||
|
require_password: requiresPassword
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -372,4 +389,4 @@ router.post('/password-strength', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ router.post('/logout', async (req, res) => {
|
|||||||
// Gallery password verification with enhanced security
|
// Gallery password verification with enhanced security
|
||||||
router.post('/gallery/verify', [
|
router.post('/gallery/verify', [
|
||||||
body('slug').notEmpty().trim(),
|
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);
|
||||||
@@ -173,55 +173,68 @@ router.post('/gallery/verify', [
|
|||||||
const { slug, password, recaptchaToken } = req.body;
|
const { slug, password, recaptchaToken } = req.body;
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
const event = await db('events')
|
||||||
// Check gallery-specific lockout
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
.first();
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify reCAPTCHA
|
|
||||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
|
||||||
if (!recaptchaValid) {
|
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
|
||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
// Don't reveal if gallery exists
|
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
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) {
|
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
if (requiresPassword) {
|
||||||
|
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||||
|
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: ipAddress,
|
ip_address: ipAddress,
|
||||||
user_agent: userAgent,
|
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 gallery or password' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful access
|
|
||||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
|
||||||
|
|
||||||
// Log successful access
|
|
||||||
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({
|
const token = jwt.sign({
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventSlug: event.slug,
|
eventSlug: event.slug,
|
||||||
@@ -246,7 +259,8 @@ router.post('/gallery/verify', [
|
|||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
allow_user_uploads: event.allow_user_uploads,
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
upload_category_id: event.upload_category_id
|
upload_category_id: event.upload_category_id,
|
||||||
|
require_password: requiresPassword
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -301,6 +315,8 @@ router.post('/gallery/share-login', [
|
|||||||
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
||||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token: jwtToken,
|
token: jwtToken,
|
||||||
event: {
|
event: {
|
||||||
@@ -312,7 +328,8 @@ router.post('/gallery/share-login', [
|
|||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
allow_user_uploads: event.allow_user_uploads,
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
upload_category_id: event.upload_category_id
|
upload_category_id: event.upload_category_id,
|
||||||
|
require_password: requiresPassword
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+104
-17
@@ -4,11 +4,34 @@ const bcrypt = require('bcrypt');
|
|||||||
const crypto = require('crypto');
|
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 { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
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 parseBooleanInput = (value, defaultValue = true) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||||
@@ -16,7 +39,17 @@ router.post('/', adminAuth, [
|
|||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('host_email').isEmail(),
|
body('host_email').isEmail(),
|
||||||
body('admin_email').isEmail(),
|
body('admin_email').isEmail(),
|
||||||
body('password').isLength({ min: 6 }),
|
body('require_password').optional().isBoolean(),
|
||||||
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
|
const requirePassword = parseBooleanInput(req.body.require_password, true);
|
||||||
|
if (!requirePassword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||||
|
throw new Error('Password must be at least 6 characters long');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -32,10 +65,28 @@ router.post('/', adminAuth, [
|
|||||||
host_email,
|
host_email,
|
||||||
admin_email,
|
admin_email,
|
||||||
password,
|
password,
|
||||||
|
require_password: requirePasswordInput = true,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
color_theme,
|
color_theme,
|
||||||
expiration_days = 30
|
expiration_days = 30
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||||
|
|
||||||
|
if (requirePassword) {
|
||||||
|
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
|
eventName: event_name
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Password does not meet security requirements',
|
||||||
|
details: passwordValidation.errors,
|
||||||
|
score: passwordValidation.score,
|
||||||
|
feedback: passwordValidation.feedback
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug
|
||||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||||
@@ -49,10 +100,15 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Generate share link (just slug/token, not full URL)
|
// Generate share link (just slug/token, not full URL)
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||||
const shareLink = `${slug}/${shareToken}`;
|
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||||
|
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||||
|
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||||
|
const shareLinkSlug = `${slug}/${shareToken}`;
|
||||||
|
|
||||||
// Hash password
|
// Hash password (or placeholder when not required)
|
||||||
const password_hash = await bcrypt.hash(password, 10);
|
const password_hash = requirePassword
|
||||||
|
? await bcrypt.hash(password, getBcryptRounds())
|
||||||
|
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
|
|
||||||
// Calculate expiration date (days after event date)
|
// Calculate expiration date (days after event date)
|
||||||
const expires_at = new Date(event_date);
|
const expires_at = new Date(event_date);
|
||||||
@@ -75,8 +131,9 @@ router.post('/', adminAuth, [
|
|||||||
password_hash,
|
password_hash,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
color_theme,
|
color_theme,
|
||||||
share_link: shareLink,
|
share_link: shareLinkSlug,
|
||||||
expires_at
|
expires_at,
|
||||||
|
require_password: formatBoolean(requirePassword)
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
@@ -88,17 +145,18 @@ router.post('/', adminAuth, [
|
|||||||
host_name: host_email.split('@')[0], // Extract name from email
|
host_name: host_email.split('@')[0], // Extract name from email
|
||||||
event_name,
|
event_name,
|
||||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: shareLink,
|
gallery_link: fullShareLink,
|
||||||
gallery_password: password,
|
gallery_password: requirePassword ? password : 'No password required',
|
||||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: eventId,
|
id: eventId,
|
||||||
slug,
|
slug,
|
||||||
share_link: shareLink,
|
share_link: fullShareLink,
|
||||||
expires_at
|
expires_at,
|
||||||
|
require_password: requirePassword
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -137,17 +195,46 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
router.put('/:id', adminAuth, async (req, res) => {
|
router.put('/:id', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = req.body;
|
const updates = { ...req.body };
|
||||||
|
|
||||||
// Don't allow updating certain fields
|
// Don't allow updating certain fields
|
||||||
delete updates.id;
|
delete updates.id;
|
||||||
delete updates.slug;
|
delete updates.slug;
|
||||||
delete updates.created_at;
|
delete updates.created_at;
|
||||||
|
delete updates.password_confirmation;
|
||||||
// If updating password, hash it
|
|
||||||
if (updates.password) {
|
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||||
updates.password_hash = await bcrypt.hash(updates.password, 10);
|
let requirePasswordUpdate;
|
||||||
delete updates.password;
|
if (hasRequirePasswordUpdate) {
|
||||||
|
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||||
|
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPasswordPlain;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||||
|
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||||
|
delete updates.password;
|
||||||
|
} else {
|
||||||
|
newPasswordPlain = updates.password;
|
||||||
|
delete updates.password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await db('events').where('id', id).first();
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||||
|
|
||||||
|
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||||
|
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPasswordPlain) {
|
||||||
|
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||||
|
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||||
|
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
}
|
}
|
||||||
|
|
||||||
await db('events').where('id', id).update(updates);
|
await db('events').where('id', id).update(updates);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
|||||||
const { slug, token } = req.params;
|
const { slug, token } = req.params;
|
||||||
|
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
.select('id', 'share_link')
|
.select('id', 'share_link')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ slug: slug })
|
.where({ slug: slug })
|
||||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
|
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
|
||||||
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text')
|
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text', 'require_password', 'color_theme')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -74,6 +74,8 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
event_name: event.event_name,
|
event_name: event.event_name,
|
||||||
event_type: event.event_type,
|
event_type: event.event_type,
|
||||||
@@ -81,11 +83,11 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
is_active: event.is_active,
|
is_active: event.is_active,
|
||||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||||
requires_password: true,
|
requires_password: requiresPassword,
|
||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
allow_downloads: event.allow_downloads !== false,
|
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||||
disable_right_click: event.disable_right_click === true,
|
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||||
watermark_downloads: event.watermark_downloads === true,
|
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||||
watermark_text: event.watermark_text
|
watermark_text: event.watermark_text
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||||
: '(Not shown for security reasons)';
|
: '(Not shown for security reasons)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (processedVariables.gallery_password === 'No password required') {
|
||||||
|
processedVariables.gallery_password = language === 'de'
|
||||||
|
? 'Kein Passwort erforderlich'
|
||||||
|
: 'No password required';
|
||||||
|
}
|
||||||
|
|
||||||
// Format dates if they exist
|
// Format dates if they exist
|
||||||
if (processedVariables.event_date) {
|
if (processedVariables.event_date) {
|
||||||
@@ -546,4 +552,4 @@ module.exports = {
|
|||||||
queueEmail,
|
queueEmail,
|
||||||
stopEmailQueueProcessor,
|
stopEmailQueueProcessor,
|
||||||
testEmailConnection
|
testEmailConnection
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import { authService, galleryService } from '../services';
|
import { authService, galleryService } from '../services';
|
||||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||||
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
import {
|
import {
|
||||||
clearActiveGallerySlug,
|
clearActiveGallerySlug,
|
||||||
clearGalleryToken,
|
clearGalleryToken,
|
||||||
@@ -18,12 +19,24 @@ interface GalleryEvent {
|
|||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
require_password?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent | null => {
|
||||||
|
if (!incoming) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...incoming,
|
||||||
|
require_password: normalizeRequirePassword(incoming.require_password, true),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface GalleryAuthContextType {
|
interface GalleryAuthContextType {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
event: GalleryEvent | null;
|
event: GalleryEvent | null;
|
||||||
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
|
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -83,7 +96,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storedEvent);
|
const parsed = JSON.parse(storedEvent);
|
||||||
if (parsed && parsed.id) {
|
if (parsed && parsed.id) {
|
||||||
setEvent(parsed);
|
const normalizedStored = normalizeEvent(parsed);
|
||||||
|
setEvent(normalizedStored);
|
||||||
|
if (normalizedStored) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
@@ -104,8 +121,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
// Fetch gallery details to hydrate context
|
// Fetch gallery details to hydrate context
|
||||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
||||||
if (galleryData?.event) {
|
if (galleryData?.event) {
|
||||||
setEvent(galleryData.event);
|
const normalizedEvent = normalizeEvent(galleryData.event);
|
||||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event));
|
setEvent(normalizedEvent);
|
||||||
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +141,12 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
if (verify?.valid) {
|
if (verify?.valid) {
|
||||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
||||||
if (response?.event) {
|
if (response?.event) {
|
||||||
setEvent(response.event);
|
const normalizedEvent = normalizeEvent(response.event);
|
||||||
|
setEvent(normalizedEvent);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
if (response.token) {
|
if (response.token) {
|
||||||
storeGalleryToken(currentSlug, response.token);
|
storeGalleryToken(currentSlug, response.token);
|
||||||
}
|
}
|
||||||
@@ -154,12 +177,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
|
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||||
setEvent(response.event);
|
const normalizedEvent = normalizeEvent(response.event);
|
||||||
|
setEvent(normalizedEvent);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
if (response.token) {
|
if (response.token) {
|
||||||
storeGalleryToken(slug, response.token);
|
storeGalleryToken(slug, response.token);
|
||||||
@@ -167,7 +191,9 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
setActiveGallerySlug(slug);
|
setActiveGallerySlug(slug);
|
||||||
|
|
||||||
// Store event data for quick reloads (non-sensitive)
|
// Store event data for quick reloads (non-sensitive)
|
||||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Invalid password');
|
setError(err.response?.data?.error || 'Invalid password');
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -496,6 +496,8 @@
|
|||||||
"expiresIn": "Galerie läuft in {{count}} Tag ab",
|
"expiresIn": "Galerie läuft in {{count}} Tag ab",
|
||||||
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
|
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
|
||||||
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
|
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
|
||||||
|
"publicGalleryTitle": "Diese Galerie ist öffentlich zugänglich",
|
||||||
|
"publicGallerySubtitle": "Fotos werden geladen...",
|
||||||
"viewGallery": "Galerie anzeigen",
|
"viewGallery": "Galerie anzeigen",
|
||||||
"downloadAll": "Alle herunterladen",
|
"downloadAll": "Alle herunterladen",
|
||||||
"downloading": "Lade {{count}} Foto herunter...",
|
"downloading": "Lade {{count}} Foto herunter...",
|
||||||
@@ -607,6 +609,7 @@
|
|||||||
"created": "Erstellt",
|
"created": "Erstellt",
|
||||||
"expires": "Läuft ab",
|
"expires": "Läuft ab",
|
||||||
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
||||||
|
"shareWithGuestsPublic": "Teilen Sie diesen Link mit Gästen. Für diese Galerie ist kein Passwort erforderlich.",
|
||||||
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
|
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
|
||||||
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
|
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
|
||||||
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
|
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
|
||||||
@@ -632,10 +635,14 @@
|
|||||||
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
||||||
"securityAccess": "Sicherheit & Zugriff",
|
"securityAccess": "Sicherheit & Zugriff",
|
||||||
"galleryPassword": "Galerie-Passwort",
|
"galleryPassword": "Galerie-Passwort",
|
||||||
|
"requirePasswordToggle": "Galerie mit Passwort schützen",
|
||||||
|
"requirePasswordToggleHelp": "Deaktivieren Sie diese Option, wenn die Galerie ohne Passwort geteilt werden soll. Jeder mit dem Link kann die Fotos ansehen.",
|
||||||
|
"publicGalleryWarning": "Öffentliche Galerien sind für jeden mit dem Link zugänglich. Aktivieren Sie gegebenenfalls Wasserzeichen und behalten Sie die Aktivität im Blick.",
|
||||||
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
|
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
|
||||||
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
||||||
"confirmPassword": "Passwort bestätigen",
|
"confirmPassword": "Passwort bestätigen",
|
||||||
"showPasswords": "Passwörter anzeigen",
|
"showPasswords": "Passwörter anzeigen",
|
||||||
|
"newPasswordLabel": "Neues Galerie-Passwort",
|
||||||
"gallerySettings": "Galerie-Einstellungen",
|
"gallerySettings": "Galerie-Einstellungen",
|
||||||
"colorTheme": "Farbthema",
|
"colorTheme": "Farbthema",
|
||||||
"galleryExpiration": "Galerie-Ablauf",
|
"galleryExpiration": "Galerie-Ablauf",
|
||||||
@@ -735,6 +742,9 @@
|
|||||||
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
||||||
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
|
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
|
||||||
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
||||||
|
"publicAccess": "Öffentlicher Zugriff",
|
||||||
|
"passwordProtected": "Passwortgeschützt",
|
||||||
|
"newPasswordRequired": "Bitte legen Sie vor dem Aktivieren des Passwortschutzes ein Passwort fest.",
|
||||||
"viewDetails": "Details anzeigen",
|
"viewDetails": "Details anzeigen",
|
||||||
"archiveEventAction": "Veranstaltung archivieren",
|
"archiveEventAction": "Veranstaltung archivieren",
|
||||||
"downloadArchiveAction": "Archiv herunterladen",
|
"downloadArchiveAction": "Archiv herunterladen",
|
||||||
@@ -853,7 +863,6 @@
|
|||||||
"security": {
|
"security": {
|
||||||
"title": "Sicherheit",
|
"title": "Sicherheit",
|
||||||
"passwordSettings": "Passworteinstellungen",
|
"passwordSettings": "Passworteinstellungen",
|
||||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
|
||||||
"minPasswordLength": "Minimale Passwortlänge",
|
"minPasswordLength": "Minimale Passwortlänge",
|
||||||
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
||||||
"passwordComplexity": "Passwort-Komplexität",
|
"passwordComplexity": "Passwort-Komplexität",
|
||||||
|
|||||||
@@ -161,6 +161,8 @@
|
|||||||
"expiresIn": "Gallery expires in {{count}} day",
|
"expiresIn": "Gallery expires in {{count}} day",
|
||||||
"expiresIn_plural": "Gallery expires in {{count}} days",
|
"expiresIn_plural": "Gallery expires in {{count}} days",
|
||||||
"downloadBefore": "Download your photos before they're no longer available.",
|
"downloadBefore": "Download your photos before they're no longer available.",
|
||||||
|
"publicGalleryTitle": "This gallery is publicly accessible",
|
||||||
|
"publicGallerySubtitle": "Loading the photos now...",
|
||||||
"viewGallery": "View Gallery",
|
"viewGallery": "View Gallery",
|
||||||
"downloadAll": "Download All",
|
"downloadAll": "Download All",
|
||||||
"downloading": "Downloading {{count}} photo...",
|
"downloading": "Downloading {{count}} photo...",
|
||||||
@@ -290,6 +292,7 @@
|
|||||||
"created": "Created",
|
"created": "Created",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
||||||
|
"shareWithGuestsPublic": "Share this link with guests. No password is required for this gallery.",
|
||||||
"resetGalleryPassword": "Reset Gallery Password",
|
"resetGalleryPassword": "Reset Gallery Password",
|
||||||
"resendCreationEmail": "Resend Creation Email",
|
"resendCreationEmail": "Resend Creation Email",
|
||||||
"creationEmailResent": "Creation email has been queued for sending",
|
"creationEmailResent": "Creation email has been queued for sending",
|
||||||
@@ -316,9 +319,13 @@
|
|||||||
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
||||||
"securityAccess": "Security & Access",
|
"securityAccess": "Security & Access",
|
||||||
"galleryPassword": "Gallery Password",
|
"galleryPassword": "Gallery Password",
|
||||||
|
"requirePasswordToggle": "Require password for this gallery",
|
||||||
|
"requirePasswordToggleHelp": "Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.",
|
||||||
|
"publicGalleryWarning": "Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.",
|
||||||
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
|
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"showPasswords": "Show passwords",
|
"showPasswords": "Show passwords",
|
||||||
|
"newPasswordLabel": "New Gallery Password",
|
||||||
"gallerySettings": "Gallery Settings",
|
"gallerySettings": "Gallery Settings",
|
||||||
"themeAndStyle": "Theme & Style",
|
"themeAndStyle": "Theme & Style",
|
||||||
"colorTheme": "Color Theme",
|
"colorTheme": "Color Theme",
|
||||||
@@ -373,6 +380,9 @@
|
|||||||
"eventsSelected_plural": "{{count}} events selected",
|
"eventsSelected_plural": "{{count}} events selected",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"archiveSelected": "Archive Selected",
|
"archiveSelected": "Archive Selected",
|
||||||
|
"publicAccess": "Public access",
|
||||||
|
"passwordProtected": "Password protected",
|
||||||
|
"newPasswordRequired": "Please set a password before enabling protection.",
|
||||||
"event": "Event",
|
"event": "Event",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
@@ -533,7 +543,6 @@
|
|||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"passwordSettings": "Password Settings",
|
"passwordSettings": "Password Settings",
|
||||||
"requirePassword": "Require password for all galleries",
|
|
||||||
"minPasswordLength": "Minimum Password Length",
|
"minPasswordLength": "Minimum Password Length",
|
||||||
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
||||||
"passwordComplexity": "Password Complexity",
|
"passwordComplexity": "Password Complexity",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { analyticsService } from '../services/analytics.service';
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||||
import { buildResourceUrl } from '../utils/url';
|
import { buildResourceUrl } from '../utils/url';
|
||||||
|
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
export const GalleryPage: React.FC = () => {
|
export const GalleryPage: React.FC = () => {
|
||||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||||
@@ -25,9 +26,11 @@ export const GalleryPage: React.FC = () => {
|
|||||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
|
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
||||||
|
|
||||||
// Fetch gallery info (public data)
|
// Fetch gallery info (public data)
|
||||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||||
|
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||||
|
|
||||||
// Fetch branding settings
|
// Fetch branding settings
|
||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
@@ -87,6 +90,30 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
||||||
|
setAutoLoginAttempted(true);
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
login(slug, '')
|
||||||
|
.then(() => {
|
||||||
|
setLoginError(null);
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
const message = error?.response?.data?.error;
|
||||||
|
if (message) {
|
||||||
|
setLoginError(message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
|
||||||
|
|
||||||
// Calculate days until expiration
|
// Calculate days until expiration
|
||||||
const daysUntilExpiration = galleryInfo
|
const daysUntilExpiration = galleryInfo
|
||||||
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
||||||
@@ -96,7 +123,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation(); // Prevent any bubbling
|
e.stopPropagation(); // Prevent any bubbling
|
||||||
|
|
||||||
if (!password.trim()) {
|
if (requiresPassword && !password.trim()) {
|
||||||
setLoginError(t('auth.pleaseEnterPassword'));
|
setLoginError(t('auth.pleaseEnterPassword'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,13 +131,14 @@ export const GalleryPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
await login(slug!, password, recaptchaToken);
|
await login(slug!, requiresPassword ? password : '', recaptchaToken);
|
||||||
|
|
||||||
// Track successful password entry
|
if (requiresPassword) {
|
||||||
analyticsService.trackGalleryEvent('password_entry', {
|
analyticsService.trackGalleryEvent('password_entry', {
|
||||||
gallery: slug,
|
gallery: slug,
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
const errorMessage = error.response?.data?.error || 'Invalid password';
|
const errorMessage = error.response?.data?.error || 'Invalid password';
|
||||||
@@ -128,11 +156,13 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track failed password entry
|
// Track failed password entry
|
||||||
analyticsService.trackGalleryEvent('password_entry', {
|
if (requiresPassword) {
|
||||||
gallery: slug,
|
analyticsService.trackGalleryEvent('password_entry', {
|
||||||
success: false,
|
gallery: slug,
|
||||||
statusCode
|
success: false,
|
||||||
});
|
statusCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the password field to allow retry
|
// Keep the password field to allow retry
|
||||||
// Do not clear the password
|
// Do not clear the password
|
||||||
@@ -311,43 +341,61 @@ export const GalleryPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Login Card */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
{requiresPassword ? (
|
||||||
|
<>
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
||||||
<Input
|
|
||||||
type="password"
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
label={t('auth.password')}
|
<Input
|
||||||
placeholder={t('auth.passwordPlaceholder')}
|
type="password"
|
||||||
value={password}
|
label={t('auth.password')}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
error={loginError || undefined}
|
value={password}
|
||||||
autoFocus
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="text-sm sm:text-base"
|
error={loginError || undefined}
|
||||||
/>
|
autoFocus
|
||||||
|
className="text-sm sm:text-base"
|
||||||
<ReCaptcha
|
/>
|
||||||
onChange={setRecaptchaToken}
|
|
||||||
onExpired={() => setRecaptchaToken(null)}
|
<ReCaptcha
|
||||||
/>
|
onChange={setRecaptchaToken}
|
||||||
|
onExpired={() => setRecaptchaToken(null)}
|
||||||
<Button
|
/>
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
<Button
|
||||||
size="lg"
|
type="submit"
|
||||||
className="w-full text-sm sm:text-base"
|
variant="primary"
|
||||||
isLoading={isLoggingIn}
|
size="lg"
|
||||||
disabled={isLoggingIn}
|
className="w-full text-sm sm:text-base"
|
||||||
>
|
isLoading={isLoggingIn}
|
||||||
{t('gallery.viewGallery')}
|
disabled={isLoggingIn}
|
||||||
</Button>
|
>
|
||||||
</form>
|
{t('gallery.viewGallery')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
||||||
{t('auth.passwordHint')}
|
{t('auth.passwordHint')}
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h2 className="text-base sm:text-lg lg:text-xl font-semibold">
|
||||||
|
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Loading size="sm" text={t('gallery.loading')} />
|
||||||
|
</div>
|
||||||
|
{loginError && (
|
||||||
|
<p className="text-xs text-red-600">{loginError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -376,4 +424,4 @@ export const GalleryPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface FormData {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
host_email: string;
|
host_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
|
require_password: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
confirm_password: string;
|
confirm_password: string;
|
||||||
welcome_message: string;
|
welcome_message: string;
|
||||||
@@ -123,6 +124,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||||
host_email: '',
|
host_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
|
require_password: true,
|
||||||
password: '',
|
password: '',
|
||||||
confirm_password: '',
|
confirm_password: '',
|
||||||
welcome_message: '',
|
welcome_message: '',
|
||||||
@@ -208,17 +210,18 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
newErrors.admin_email = t('validation.invalidEmailFormat');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password) {
|
if (formData.require_password) {
|
||||||
newErrors.password = t('validation.passwordRequired');
|
if (!formData.password) {
|
||||||
} else if (formData.password.length < 6) {
|
newErrors.password = t('validation.passwordRequired');
|
||||||
newErrors.password = t('validation.passwordMinLength');
|
} else if (formData.password.length < 6) {
|
||||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
newErrors.password = t('validation.passwordMinLength');
|
||||||
// Prevent simple numeric passwords like "123456"
|
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
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) {
|
if (formData.password !== formData.confirm_password) {
|
||||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||||
@@ -244,7 +247,8 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
event_date: formData.event_date,
|
event_date: formData.event_date,
|
||||||
host_email: formData.host_email,
|
host_email: formData.host_email,
|
||||||
admin_email: formData.admin_email,
|
admin_email: formData.admin_email,
|
||||||
password: formData.password,
|
require_password: formData.require_password,
|
||||||
|
password: formData.require_password ? formData.password : '',
|
||||||
welcome_message: formData.welcome_message || '',
|
welcome_message: formData.welcome_message || '',
|
||||||
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
|
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
|
||||||
expiration_days: formData.expires_in_days,
|
expiration_days: formData.expires_in_days,
|
||||||
@@ -426,81 +430,109 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
<Card padding="md" className="mb-6">
|
<Card padding="md" className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-4">
|
||||||
{/* Password */}
|
<label className="flex items-start gap-2">
|
||||||
<div>
|
<input
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
type="checkbox"
|
||||||
{t('events.galleryPassword')}
|
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
</label>
|
checked={formData.require_password}
|
||||||
<div className="relative">
|
onChange={(e) => {
|
||||||
<Input
|
const checked = e.target.checked;
|
||||||
id="password"
|
setFormData(prev => ({ ...prev, require_password: checked }));
|
||||||
type={showPassword ? 'text' : 'password'}
|
if (!checked) {
|
||||||
value={formData.password}
|
setErrors(prev => ({ ...prev, password: '', confirm_password: '' }));
|
||||||
onChange={handleInputChange('password')}
|
}
|
||||||
error={errors.password}
|
}}
|
||||||
placeholder={t('events.enterPassword')}
|
/>
|
||||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
<div>
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
|
||||||
className="pr-10"
|
<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.')}
|
||||||
<button
|
</p>
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
||||||
style={{ top: errors.password ? '0' : '0' }}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</label>
|
||||||
{/* Password Generator */}
|
|
||||||
<div className="mt-2">
|
|
||||||
<PasswordGenerator
|
|
||||||
eventName={formData.event_name}
|
|
||||||
eventDate={formData.event_date}
|
|
||||||
eventType={formData.event_type}
|
|
||||||
onPasswordGenerated={handlePasswordGenerated}
|
|
||||||
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirm Password */}
|
{!formData.require_password && (
|
||||||
<div>
|
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||||
{t('events.confirmPassword')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="confirm_password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={formData.confirm_password}
|
|
||||||
onChange={handleInputChange('confirm_password')}
|
|
||||||
error={errors.confirm_password}
|
|
||||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
|
||||||
className="pr-10"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
||||||
style={{ top: errors.confirm_password ? '0' : '0' }}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{formData.require_password && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.galleryPassword')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange('password')}
|
||||||
|
error={errors.password}
|
||||||
|
placeholder={t('events.enterPassword')}
|
||||||
|
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 text-neutral-400" />}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
style={{ top: errors.password ? '0' : '0' }}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<PasswordGenerator
|
||||||
|
eventName={formData.event_name}
|
||||||
|
eventDate={formData.event_date}
|
||||||
|
eventType={formData.event_type}
|
||||||
|
onPasswordGenerated={handlePasswordGenerated}
|
||||||
|
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.confirmPassword')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirm_password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={formData.confirm_password}
|
||||||
|
onChange={handleInputChange('confirm_password')}
|
||||||
|
error={errors.confirm_password}
|
||||||
|
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
style={{ top: errors.confirm_password ? '0' : '0' }}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -645,4 +677,4 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
CreateEventPage.displayName = 'CreateEventPage';
|
CreateEventPage.displayName = 'CreateEventPage';
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface FormData {
|
|||||||
host_name: string;
|
host_name: string;
|
||||||
host_email: string;
|
host_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
|
require_password: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
confirm_password: string;
|
confirm_password: string;
|
||||||
welcome_message: string;
|
welcome_message: string;
|
||||||
@@ -88,6 +89,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
host_name: '',
|
host_name: '',
|
||||||
host_email: '',
|
host_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
|
require_password: true,
|
||||||
password: '',
|
password: '',
|
||||||
confirm_password: '',
|
confirm_password: '',
|
||||||
welcome_message: '',
|
welcome_message: '',
|
||||||
@@ -198,17 +200,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
newErrors.admin_email = t('validation.invalidEmailFormat');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password) {
|
if (formData.require_password) {
|
||||||
newErrors.password = t('validation.passwordRequired');
|
if (!formData.password) {
|
||||||
} else if (formData.password.length < 6) {
|
newErrors.password = t('validation.passwordRequired');
|
||||||
newErrors.password = t('validation.passwordMinLength');
|
} else if (formData.password.length < 6) {
|
||||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
newErrors.password = t('validation.passwordMinLength');
|
||||||
// Prevent simple numeric passwords like "123456"
|
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
// 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) {
|
if (formData.password !== formData.confirm_password) {
|
||||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||||
@@ -235,7 +239,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
host_name: formData.host_name,
|
host_name: formData.host_name,
|
||||||
host_email: formData.host_email,
|
host_email: formData.host_email,
|
||||||
admin_email: formData.admin_email,
|
admin_email: formData.admin_email,
|
||||||
password: formData.password,
|
require_password: formData.require_password,
|
||||||
|
password: formData.require_password ? formData.password : '',
|
||||||
welcome_message: formData.welcome_message || '',
|
welcome_message: formData.welcome_message || '',
|
||||||
color_theme: JSON.stringify(formData.theme_config),
|
color_theme: JSON.stringify(formData.theme_config),
|
||||||
expiration_days: formData.expires_in_days,
|
expiration_days: formData.expires_in_days,
|
||||||
@@ -495,51 +500,87 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<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,
|
||||||
|
}));
|
||||||
|
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
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
label={t('events.galleryPassword')}
|
label={t('events.confirmPassword')}
|
||||||
placeholder={t('events.passwordPlaceholder')}
|
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||||
value={formData.password}
|
value={formData.confirm_password}
|
||||||
onChange={handleInputChange('password')}
|
onChange={handleInputChange('confirm_password')}
|
||||||
error={errors.password}
|
error={errors.confirm_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" />}
|
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>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Key,
|
Key,
|
||||||
Mail,
|
Mail,
|
||||||
MessageSquare
|
MessageSquare,
|
||||||
|
Lock,
|
||||||
|
Eye,
|
||||||
|
EyeOff
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays } from 'date-fns';
|
import { parseISO, differenceInDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -27,6 +30,7 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
|||||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
|
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||||
import { archiveService } from '../../services/archive.service';
|
import { archiveService } from '../../services/archive.service';
|
||||||
import { externalMediaService } from '../../services/externalMedia.service';
|
import { externalMediaService } from '../../services/externalMedia.service';
|
||||||
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
|
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
|
||||||
@@ -121,6 +125,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: string;
|
host_name: string;
|
||||||
source_mode: 'managed' | 'reference';
|
source_mode: 'managed' | 'reference';
|
||||||
external_path: string;
|
external_path: string;
|
||||||
|
require_password: boolean;
|
||||||
|
new_password: string;
|
||||||
|
confirm_new_password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -134,6 +141,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: '',
|
host_name: '',
|
||||||
source_mode: 'managed',
|
source_mode: 'managed',
|
||||||
external_path: '',
|
external_path: '',
|
||||||
|
require_password: true,
|
||||||
|
new_password: '',
|
||||||
|
confirm_new_password: '',
|
||||||
});
|
});
|
||||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||||
feedback_enabled: false,
|
feedback_enabled: false,
|
||||||
@@ -156,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const [importing, setImporting] = useState<boolean>(false);
|
const [importing, setImporting] = useState<boolean>(false);
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||||
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||||
|
|
||||||
@@ -274,7 +285,12 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: event.host_name || '',
|
host_name: event.host_name || '',
|
||||||
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||||
external_path: event.external_path || '',
|
external_path: event.external_path || '',
|
||||||
|
require_password: normalizeRequirePassword(event.require_password),
|
||||||
|
new_password: '',
|
||||||
|
confirm_new_password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setShowNewPassword(false);
|
||||||
|
|
||||||
// Set feedback settings if available
|
// Set feedback settings if available
|
||||||
if (eventFeedbackSettings) {
|
if (eventFeedbackSettings) {
|
||||||
@@ -324,6 +340,26 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
|
|
||||||
const externalPathToSave = editForm.external_path?.trim() || '';
|
const externalPathToSave = editForm.external_path?.trim() || '';
|
||||||
|
|
||||||
|
const currentRequirePassword = normalizeRequirePassword(event.require_password);
|
||||||
|
const requirePasswordChanged = editForm.require_password !== currentRequirePassword;
|
||||||
|
|
||||||
|
if (editForm.require_password) {
|
||||||
|
if (requirePasswordChanged && !editForm.new_password) {
|
||||||
|
toast.error(t('events.newPasswordRequired', 'Please set a password before enabling protection.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editForm.new_password) {
|
||||||
|
if (editForm.new_password.length < 6) {
|
||||||
|
toast.error(t('validation.passwordMinLength'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editForm.new_password !== editForm.confirm_new_password) {
|
||||||
|
toast.error(t('validation.passwordsDoNotMatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (editForm.source_mode === 'reference' && !externalPathToSave) {
|
if (editForm.source_mode === 'reference' && !externalPathToSave) {
|
||||||
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
||||||
return;
|
return;
|
||||||
@@ -333,6 +369,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
expires_at: editForm.expires_at,
|
expires_at: editForm.expires_at,
|
||||||
allow_user_uploads: editForm.allow_user_uploads,
|
allow_user_uploads: editForm.allow_user_uploads,
|
||||||
|
require_password: editForm.require_password,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include fields that have defined values
|
// Only include fields that have defined values
|
||||||
@@ -355,6 +392,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||||
updateData.host_name = editForm.host_name;
|
updateData.host_name = editForm.host_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editForm.new_password) {
|
||||||
|
updateData.password = editForm.new_password;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any keys with undefined values
|
// Remove any keys with undefined values
|
||||||
Object.keys(updateData).forEach(key => {
|
Object.keys(updateData).forEach(key => {
|
||||||
@@ -437,6 +478,15 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{format(parseISO(event.event_date), 'PPP')}
|
{format(parseISO(event.event_date), 'PPP')}
|
||||||
</span>
|
</span>
|
||||||
<span className="capitalize">{event.event_type}</span>
|
<span className="capitalize">{event.event_type}</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
isGalleryPublic(event.require_password)
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||||
|
</span>
|
||||||
{event.is_archived ? (
|
{event.is_archived ? (
|
||||||
<span className="text-neutral-500 flex items-center">
|
<span className="text-neutral-500 flex items-center">
|
||||||
<Archive className="w-4 h-4 mr-1" />
|
<Archive className="w-4 h-4 mr-1" />
|
||||||
@@ -641,6 +691,84 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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={editForm.require_password}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setEditForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
require_password: checked,
|
||||||
|
new_password: checked ? prev.new_password : '',
|
||||||
|
confirm_new_password: checked ? prev.confirm_new_password : '',
|
||||||
|
}));
|
||||||
|
if (!checked) {
|
||||||
|
setShowNewPassword(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{!editForm.require_password && (
|
||||||
|
<div className="mt-2 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>
|
||||||
|
|
||||||
|
{editForm.require_password && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.newPasswordLabel', 'New gallery password')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
value={editForm.new_password}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, new_password: e.target.value }))}
|
||||||
|
placeholder={t('events.enterPassword')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
>
|
||||||
|
{showNewPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.confirmPassword')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
value={editForm.confirm_new_password}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, confirm_new_password: e.target.value }))}
|
||||||
|
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('events.sourceMode', 'Source Mode')}
|
{t('events.sourceMode', 'Source Mode')}
|
||||||
@@ -848,7 +976,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-neutral-600 mt-2">
|
<p className="text-sm text-neutral-600 mt-2">
|
||||||
{t('events.shareWithGuests')}
|
{isGalleryPublic(event.require_password)
|
||||||
|
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
|
||||||
|
: t('events.shareWithGuests')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!event.is_archived && (
|
{!event.is_archived && (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../compone
|
|||||||
import { BulkArchiveModal } from '../../components/admin';
|
import { BulkArchiveModal } from '../../components/admin';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { isGalleryPublic } from '../../utils/accessControl';
|
||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -428,6 +429,17 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
||||||
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||||
|
isGalleryPublic(event.require_password)
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
<td className="px-6 py-4 text-sm text-neutral-700">
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { LoginResponse, GalleryAuthResponse } from '../types';
|
import type { LoginResponse, GalleryAuthResponse } from '../types';
|
||||||
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
|
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
|
||||||
|
...response,
|
||||||
|
event: response.event
|
||||||
|
? {
|
||||||
|
...response.event,
|
||||||
|
require_password: normalizeRequirePassword((response.event as any)?.require_password, true),
|
||||||
|
}
|
||||||
|
: response.event,
|
||||||
|
});
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
// Admin authentication
|
// Admin authentication
|
||||||
@@ -24,7 +35,7 @@ export const authService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Gallery authentication
|
// Gallery authentication
|
||||||
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
async verifyGalleryPassword(slug: string, password?: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||||
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
|
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
|
||||||
slug,
|
slug,
|
||||||
password,
|
password,
|
||||||
@@ -32,7 +43,7 @@ export const authService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Token is now handled by GalleryAuthContext with slug-specific storage
|
// Token is now handled by GalleryAuthContext with slug-specific storage
|
||||||
return response.data;
|
return normalizeGalleryResponse(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
|
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
|
||||||
@@ -40,7 +51,7 @@ export const authService = {
|
|||||||
slug,
|
slug,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
return response.data;
|
return normalizeGalleryResponse(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async galleryLogout(slug?: string | null) {
|
async galleryLogout(slug?: string | null) {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { Event } from '../types';
|
import type { Event } from '../types';
|
||||||
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
|
const normalizeEvent = (event: Event): Event => ({
|
||||||
|
...event,
|
||||||
|
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||||
|
});
|
||||||
|
|
||||||
interface CreateEventData {
|
interface CreateEventData {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
@@ -7,6 +13,7 @@ interface CreateEventData {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
host_email: string;
|
host_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
|
require_password?: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
@@ -28,6 +35,7 @@ interface UpdateEventData {
|
|||||||
event_date?: string;
|
event_date?: string;
|
||||||
host_email?: string;
|
host_email?: string;
|
||||||
admin_email?: string;
|
admin_email?: string;
|
||||||
|
require_password?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
@@ -64,19 +72,25 @@ export const eventsService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
||||||
return response.data;
|
const data: any = response.data;
|
||||||
|
if (Array.isArray(data?.events)) {
|
||||||
|
data.events = data.events.map((event: Event) => normalizeEvent(event));
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
return data.map((event: Event) => normalizeEvent(event)) as any;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single event details (admin)
|
// Get single event details (admin)
|
||||||
async getEvent(id: number): Promise<Event> {
|
async getEvent(id: number): Promise<Event> {
|
||||||
const response = await api.get<Event>(`/admin/events/${id}`);
|
const response = await api.get<Event>(`/admin/events/${id}`);
|
||||||
return response.data;
|
return normalizeEvent(response.data as Event);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create new event (admin)
|
// Create new event (admin)
|
||||||
async createEvent(data: CreateEventData): Promise<Event> {
|
async createEvent(data: CreateEventData): Promise<Event> {
|
||||||
const response = await api.post<Event>('/admin/events', data);
|
const response = await api.post<Event>('/admin/events', data);
|
||||||
return response.data;
|
return normalizeEvent(response.data as Event);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update event (admin)
|
// Update event (admin)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||||
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
export const galleryService = {
|
export const galleryService = {
|
||||||
// Verify share token
|
// Verify share token
|
||||||
@@ -12,7 +13,11 @@ export const galleryService = {
|
|||||||
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
||||||
const params = token ? { token } : {};
|
const params = token ? { token } : {};
|
||||||
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
|
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
|
||||||
return response.data;
|
const data = response.data;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
requires_password: normalizeRequirePassword((data as any)?.requires_password, true),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get gallery photos (requires auth)
|
// Get gallery photos (requires auth)
|
||||||
@@ -29,7 +34,17 @@ export const galleryService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
||||||
return response.data;
|
const data = response.data;
|
||||||
|
const normalizedEvent = data?.event
|
||||||
|
? {
|
||||||
|
...data.event,
|
||||||
|
require_password: normalizeRequirePassword((data.event as any)?.require_password, true),
|
||||||
|
}
|
||||||
|
: data.event;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
event: normalizedEvent,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Download single photo
|
// Download single photo
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface Event {
|
|||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
archive_path?: string;
|
archive_path?: string;
|
||||||
archived_at?: string;
|
archived_at?: string;
|
||||||
|
require_password?: boolean;
|
||||||
photo_count?: number;
|
photo_count?: number;
|
||||||
total_size?: number;
|
total_size?: number;
|
||||||
recent_photos?: Array<{
|
recent_photos?: Array<{
|
||||||
@@ -92,6 +93,7 @@ export interface GalleryData {
|
|||||||
disable_right_click?: boolean;
|
disable_right_click?: boolean;
|
||||||
watermark_downloads?: boolean;
|
watermark_downloads?: boolean;
|
||||||
watermark_text?: string;
|
watermark_text?: string;
|
||||||
|
require_password?: boolean;
|
||||||
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
image_quality?: number;
|
image_quality?: number;
|
||||||
use_canvas_rendering?: boolean;
|
use_canvas_rendering?: boolean;
|
||||||
@@ -134,6 +136,7 @@ export interface GalleryAuthResponse {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
allow_user_uploads?: boolean;
|
allow_user_uploads?: boolean;
|
||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
|
require_password?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const normalizeRequirePassword = (value: unknown, defaultValue = true): boolean => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isGalleryPublic = (value: unknown, defaultValue = true): boolean => {
|
||||||
|
return !normalizeRequirePassword(value, defaultValue);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user