feat: photo visibility control with client access (#172)

Add two-tier gallery access system allowing clients (e.g., wedding couples) to
review and hide photos before the gallery is shared with guests.

Backend:
- Migration 074: add visibility column to photos, client_access_enabled/
  client_password_hash/client_share_token to events
- Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN
- Gallery photo list filters hidden photos for guests, shows all for clients
- Visibility toggle endpoints (single + bulk) for client access level
- Admin event CRUD supports client access fields
- Email template includes client access link + PIN (EN/DE/RU/PT)

Frontend:
- ClientAccessPage: PIN entry form at /gallery/:slug/client-access
- GalleryView: client mode banner, visibility counter, toggle controls
- GridGalleryLayout: eye/eye-off overlay per photo for clients
- AdminPhotoGrid: visibility badge, bulk Hide/Show buttons
- EventDetailsPage: Client Access settings section (toggle, PIN, link)
- CreateEventPage: client access toggle + PIN in event creation form
- GalleryAuthContext: accessLevel/isClient/clientLogin support
- New complete pt-BR locale (pt.json) with all translations
- Client access i18n keys for EN, DE, RU, PT
This commit is contained in:
Paul Nothaft
2026-03-17 13:04:41 +01:00
parent 999c66dbbf
commit e1b6e43e52
25 changed files with 2217 additions and 1112 deletions
+2 -1
View File
@@ -102,8 +102,9 @@ async function verifyGalleryAccess(req, res, next) {
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.accessLevel = decoded.accessLevel || 'guest';
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler)
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
+63 -15
View File
@@ -124,6 +124,8 @@ const mapEventForApi = (event) => {
host_email,
customer_name,
customer_email,
password_hash: _ph,
client_password_hash: _cph,
...rest
} = event;
@@ -208,7 +210,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString()
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -258,7 +263,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// Hero image anchor position (#162)
hero_image_anchor = 'center',
// Photo cap
photo_cap = null
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
@@ -419,7 +427,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null
photo_cap: photo_cap || null,
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex')
} : {})
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -453,21 +467,32 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// Language detection is handled by email processor
if (customerEmail) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
};
// Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first();
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || '';
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify({
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
}),
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
@@ -709,7 +734,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('regenerate_client_token').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -788,6 +817,25 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
}
// Handle client access fields (#172)
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
// Auto-generate client share token when first enabling
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
delete updates.client_password;
} else {
delete updates.client_password;
}
if (updates.regenerate_client_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
delete updates.regenerate_client_token;
// Log the update request for debugging
logger.debug('Update event request', {
id,
+15 -1
View File
@@ -587,7 +587,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
const { category_id, visibility } = req.body;
// Verify photo belongs to event
const photo = await db('photos')
@@ -601,6 +601,13 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
// Prepare update data
const updateData = {};
// Handle visibility update (#172)
if (visibility !== undefined) {
if (['visible', 'hidden'].includes(visibility)) {
updateData.visibility = visibility;
}
}
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (category_id === 'individual' || category_id === 'collage') {
@@ -736,6 +743,13 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
// Prepare update data
const updateData = {};
// Handle visibility update (#172)
if (updates.visibility !== undefined) {
if (['visible', 'hidden'].includes(updates.visibility)) {
updateData.visibility = updates.visibility;
}
}
if (updates.category_id !== undefined) {
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
+76
View File
@@ -290,6 +290,82 @@ router.post('/gallery/verify', [
}
});
// Client access login (PIN-based)
router.post('/gallery/:slug/client-login', [
body('password').notEmpty().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug } = req.params;
const { password } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event || !event.client_access_enabled || !event.client_password_hash) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
const lockoutStatus = await checkAccountLockout(`client:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const validPassword = await bcrypt.compare(password, event.client_password_hash);
if (!validPassword) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
await trackSuccessfulLogin(`client:${slug}`, ipAddress, userAgent);
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
accessLevel: 'client',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setGalleryAuthCookies(res, token, event.slug);
res.json({
token,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: true
},
accessLevel: 'client'
});
} catch (error) {
logger.error('Client login error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Share link authentication (token-based)
router.post('/gallery/share-login', [
body('slug').notEmpty().trim(),
+103 -6
View File
@@ -198,10 +198,18 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
// Build the query with sorting
const sortOrder = order === 'asc' ? 'asc' : 'desc';
const isClient = req.accessLevel === 'client';
let photosQuery = db('photos')
.where('photos.event_id', req.event.id)
.select('photos.*');
// Guests only see visible photos; clients see all
if (!isClient) {
photosQuery = photosQuery.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
}
// Apply sort option
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null
@@ -414,12 +422,20 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
height: photo.height || null,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
// EXIF capture date
captured_at: photo.captured_at || null,
// Media type
media_type: photo.media_type || null,
mime_type: photo.mime_type || null,
duration: photo.duration || null,
// Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 0,
comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0
favorite_count: photo.favorite_count || 0,
// Visibility (only included for clients)
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
};
})
});
@@ -429,24 +445,91 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
}
});
// Toggle photo visibility (client-only)
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoId } = req.params;
const { visibility } = req.body;
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
await db('photos')
.where({ id: photoId, event_id: req.event.id })
.update({ visibility });
res.json({ message: 'Photo visibility updated', visibility });
} catch (error) {
logger.error('Error updating photo visibility:', error);
res.status(500).json({ error: 'Failed to update photo visibility' });
}
});
// Bulk toggle photo visibility (client-only)
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoIds, visibility } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
const count = await db('photos')
.whereIn('id', photoIds)
.where('event_id', req.event.id)
.update({ visibility });
res.json({ message: `${count} photos updated`, visibility });
} catch (error) {
logger.error('Error bulk updating photo visibility:', error);
res.status(500).json({ error: 'Failed to update photo visibility' });
}
});
// Download single photo
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
@@ -745,11 +828,15 @@ router.get('/:slug/photo/:photoId',
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
@@ -935,6 +1022,11 @@ router.get('/:slug/thumbnail/:photoId',
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Ensure thumbnail exists and is valid, regenerate if needed
const thumbnailPath = await ensureThumbnail(photo);
@@ -1013,6 +1105,11 @@ router.get('/:slug/hero/:photoId',
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video - videos don't get hero images
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
+47
View File
@@ -353,6 +353,53 @@ async function processTemplate(template, variables, language = 'en') {
htmlBody = htmlTemplate(processedVariables);
textBody = textTemplate(processedVariables);
// Inject client access section if client_link is provided (#172)
if (processedVariables.client_link) {
const clientAccessI18n = {
de: {
label: 'Kundenzugang (Privat)',
desc: 'Fotos überprüfen und deren Sichtbarkeit festlegen, bevor die Galerie geteilt wird:',
link: 'Kundenzugang öffnen',
warning: 'Diesen Link nicht teilen — er ermöglicht das Ausblenden von Fotos in der Gästegalerie.',
},
ru: {
label: 'Доступ клиента (Личный)',
desc: 'Просмотрите и управляйте видимостью фотографий перед тем, как поделиться галереей с гостями:',
link: 'Открыть доступ клиента',
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
},
pt: {
label: 'Acesso do Cliente (Privado)',
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
link: 'Abrir Acesso do Cliente',
warning: 'Não compartilhe este link — ele permite ocultar fotos da galeria de convidados.',
},
en: {
label: 'Client Access (Private)',
desc: 'Review and manage photo visibility before sharing with guests:',
link: 'Open Client Access',
warning: 'Do not share this link — it allows hiding photos from the guest gallery.',
},
};
const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
const clientAccessLabel = ci18n.label;
const clientAccessDesc = ci18n.desc;
const clientAccessLink = ci18n.link;
const clientAccessWarning = ci18n.warning;
const pinLabel = 'PIN';
htmlBody += `
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<strong style="font-size: 15px;">&#128274; ${clientAccessLabel}</strong>
<p style="margin: 10px 0 8px;">${clientAccessDesc}</p>
<p style="margin: 8px 0;">
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${clientAccessLink}</a>
</p>
<p style="margin: 8px 0;">${pinLabel}: <strong>${processedVariables.client_password}</strong></p>
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">&#9888;&#65039; ${clientAccessWarning}</p>
</div>`;
}
// Wrap HTML body in styled template
const styledHtmlBody = await wrapEmailHtml(htmlBody, subject, language);