From ad4e5a7506bc9217d1223101da0bc112047532a8 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 11 Apr 2026 07:48:23 +0200 Subject: [PATCH] feat: guest selections with per-person identity (#292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new "Per-guest selections" identity mode for event feedback, letting each visitor register under their own name so their likes/favorites/comments/ratings are tracked independently. Includes admin insights (list, per-guest detail, aggregate view, export) and advanced identity features (forget-me, email recovery, invite tokens, merge). New event-level setting - event_feedback_settings.identity_mode = 'simple' | 'guest' (default 'simple' → zero behavior change for existing events). - Admin UI radio under Feedback Settings to toggle per event. Root cause of the previous "all guests share state" bug - generateGuestIdentifier() was sha256(ip + userAgent), so every visitor on the same WiFi + similar device collided into one identity. - Now: when a verified guest JWT is present (x-guest-token header), req.guest.identifier takes precedence — per-person rate limits and per-person deduplication. Phase 1 — identity layer - Migration 078: new gallery_guests, guest_invites, guest_verification_ codes tables; identity_mode column + check constraint; nullable guest_id FK on photo_feedback. - New guest JWT type scoped to (eventId, guestId). - New middleware guestAuth.resolveGuest (non-blocking) + requireGuest. - POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me. - Gallery feedback route enforces guest identity in guest mode and reads name/email from the verified token (never from the body). - Frontend GuestIdentityContext + GuestNamePromptModal; axios interceptor injects x-guest-token on gallery API calls. - Feedback-only blocking: gallery opens freely, prompt only on first interactive feedback action. - Admin "Guests" tab (conditional on identity_mode='guest') with the AdminGuestsList component. Phase 2 — admin insights - GET /admin/events/:eventId/guests list + aggregated counts. - GET /admin/events/:eventId/guests/:guestId detail with per-type groupings; AdminGuestDetail modal with thumbnail grid + tabs. - GET /admin/events/:eventId/guests/aggregate sorted by distinct guest pick count; GuestSelectionsAggregate component. - Per-guest export (txt/csv/json) and bulk export-all ZIP. Phase 3 — polish - 3.1 Self-service forget-me link in gallery footer. - 3.2 Email-based identity recovery: POST /guest/recover sends a 6-digit code via the existing emailProcessor, POST /guest/verify exchanges it for a token (rate-limited, enumeration-safe). - 3.3 Admin invite tokens: pre-mint identities, share URLs with ?invite=, single-use redemption stripping the param from history. - 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources. Shared helper - useGalleryFeedbackAction hook wraps the identity-check logic for inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/ Timeline/Premium layouts. Backwards compatibility - Existing events default to 'simple' after migration; behavior unchanged. - Legacy photo_feedback rows keep guest_id NULL; admin shows them in the generic feedback moderation view as before. - feedback_count denormalized stat now uses COALESCE(guest_id, guest_identifier) so per-guest counts are accurate without touching legacy rows. Verified end-to-end against local Docker - Migration clean on existing data. - Simple mode unchanged (no prompt, legacy flow). - Guest mode: Alice registers on click, tokens persist in sessionStorage, feedback rows carry guest_id. - Carol via invite link auto-redeems, sees Alice's "1 likes" badge. - Admin Guests tab shows both with correct counts; detail modal displays thumbnail grid with badges; aggregate view sorts by picker count (photo 227 = 2, others = 1); CSV/JSON export matches DB. - Merge Carol into Alice: feedback reassigned, Carol soft-deleted, Alice count = 4. --- .../migrations/core/078_add_guest_identity.js | 120 ++++ backend/server.js | 2 + backend/src/middleware/feedbackRateLimit.js | 13 +- backend/src/middleware/guestAuth.js | 105 +++ backend/src/routes/adminGuests.js | 609 ++++++++++++++++++ backend/src/routes/gallery.js | 6 +- backend/src/routes/galleryFeedback.js | 94 ++- backend/src/routes/galleryGuests.js | 409 ++++++++++++ backend/src/services/feedbackService.js | 99 ++- backend/src/services/guestRecoveryService.js | 131 ++++ backend/src/utils/feedbackValidation.js | 4 +- backend/src/utils/tokenUtils.js | 29 + .../src/components/admin/AdminGuestDetail.tsx | 205 ++++++ .../src/components/admin/AdminGuestsList.tsx | 344 ++++++++++ .../src/components/admin/FeedbackSettings.tsx | 71 +- .../components/admin/GuestInviteDialog.tsx | 194 ++++++ .../admin/GuestSelectionsAggregate.tsx | 69 ++ frontend/src/components/admin/index.ts | 4 + .../src/components/gallery/GalleryLayout.tsx | 28 +- .../src/components/gallery/GalleryView.tsx | 10 + .../gallery/GuestNamePromptModal.tsx | 156 +++++ .../components/gallery/GuestRecoveryModal.tsx | 173 +++++ .../src/components/gallery/PhotoComments.tsx | 21 +- .../src/components/gallery/PhotoFavorites.tsx | 16 +- .../src/components/gallery/PhotoLikes.tsx | 20 +- .../src/components/gallery/PhotoRating.tsx | 22 +- .../gallery/layouts/CarouselGalleryLayout.tsx | 16 + .../gallery/layouts/GalleryPremiumLayout.tsx | 26 +- .../gallery/layouts/GridGalleryLayout.tsx | 21 + .../layouts/JustifiedGalleryLayout.tsx | 21 + .../gallery/layouts/MasonryGalleryLayout.tsx | 13 + .../gallery/layouts/MosaicGalleryLayout.tsx | 16 + .../gallery/layouts/TimelineGalleryLayout.tsx | 16 + frontend/src/config/api.ts | 20 + .../src/contexts/GuestIdentityContext.tsx | 224 +++++++ .../src/hooks/useGalleryFeedbackAction.ts | 69 ++ frontend/src/i18n/locales/en.json | 7 +- frontend/src/pages/admin/EventDetailsPage.tsx | 21 +- frontend/src/services/feedback.service.ts | 3 + frontend/src/services/guests.service.ts | 163 +++++ frontend/src/utils/guestIdentityStorage.ts | 85 +++ 41 files changed, 3609 insertions(+), 66 deletions(-) create mode 100644 backend/migrations/core/078_add_guest_identity.js create mode 100644 backend/src/middleware/guestAuth.js create mode 100644 backend/src/routes/adminGuests.js create mode 100644 backend/src/routes/galleryGuests.js create mode 100644 backend/src/services/guestRecoveryService.js create mode 100644 frontend/src/components/admin/AdminGuestDetail.tsx create mode 100644 frontend/src/components/admin/AdminGuestsList.tsx create mode 100644 frontend/src/components/admin/GuestInviteDialog.tsx create mode 100644 frontend/src/components/admin/GuestSelectionsAggregate.tsx create mode 100644 frontend/src/components/gallery/GuestNamePromptModal.tsx create mode 100644 frontend/src/components/gallery/GuestRecoveryModal.tsx create mode 100644 frontend/src/contexts/GuestIdentityContext.tsx create mode 100644 frontend/src/hooks/useGalleryFeedbackAction.ts create mode 100644 frontend/src/services/guests.service.ts create mode 100644 frontend/src/utils/guestIdentityStorage.ts diff --git a/backend/migrations/core/078_add_guest_identity.js b/backend/migrations/core/078_add_guest_identity.js new file mode 100644 index 00000000..ef01f93d --- /dev/null +++ b/backend/migrations/core/078_add_guest_identity.js @@ -0,0 +1,120 @@ +/** + * Add guest identity layer for per-person photo selections (issue #292). + * + * Adds: + * - gallery_guests — persistent guest profiles per event + * - guest_invites — pre-minted invite tokens (Phase 3.3) + * - guest_verification_codes — email-based identity recovery (Phase 3.2) + * - event_feedback_settings.identity_mode ('simple' | 'guest', default 'simple') + * - photo_feedback.guest_id FK — links feedback to gallery_guests (nullable) + * + * All changes are additive. Existing events default to 'simple' mode so behavior + * is unchanged. Legacy photo_feedback rows keep NULL guest_id. + */ + +exports.up = async function(knex) { + // 1. gallery_guests — persistent per-person identity within an event. + const hasGalleryGuests = await knex.schema.hasTable('gallery_guests'); + if (!hasGalleryGuests) { + await knex.schema.createTable('gallery_guests', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.string('name', 100).notNullable(); + table.string('email', 255); + table.string('identifier', 64).notNullable(); // UUIDv4 issued server-side + table.string('ip_address_last', 45); + table.text('user_agent_last'); + table.timestamp('email_verified_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('last_seen_at').defaultTo(knex.fn.now()); + table.boolean('is_deleted').defaultTo(false); + + table.unique(['event_id', 'identifier']); + table.index(['event_id']); + table.index(['event_id', 'email']); + }); + } + + // 2. guest_invites — pre-minted one-time-use tokens for invited guests. + const hasGuestInvites = await knex.schema.hasTable('guest_invites'); + if (!hasGuestInvites) { + await knex.schema.createTable('guest_invites', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.integer('guest_id').notNullable().references('id').inTable('gallery_guests').onDelete('CASCADE'); + table.string('token', 64).notNullable().unique(); + table.integer('created_by_admin_id').references('id').inTable('admin_users'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('redeemed_at'); + table.timestamp('revoked_at'); + + table.index(['event_id']); + table.index(['guest_id']); + }); + } + + // 3. guest_verification_codes — short-lived codes for email-based recovery. + const hasGuestVerificationCodes = await knex.schema.hasTable('guest_verification_codes'); + if (!hasGuestVerificationCodes) { + await knex.schema.createTable('guest_verification_codes', (table) => { + table.increments('id').primary(); + table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE'); + table.string('email', 255).notNullable(); + table.string('code_hash', 128).notNullable(); // bcrypt hash of 6-digit code + table.integer('attempts').defaultTo(0); + table.timestamp('expires_at').notNullable(); + table.timestamp('consumed_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + + table.index(['event_id', 'email']); + table.index(['expires_at']); + }); + } + + // 4. event_feedback_settings.identity_mode + const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode'); + if (!hasIdentityMode) { + await knex.schema.alterTable('event_feedback_settings', (table) => { + table.string('identity_mode', 16).notNullable().defaultTo('simple'); + }); + if (knex.client.config.client === 'pg') { + await knex.raw(` + ALTER TABLE event_feedback_settings + ADD CONSTRAINT event_feedback_settings_identity_mode_check + CHECK (identity_mode IN ('simple','guest')) + `); + } + } + + // 5. photo_feedback.guest_id FK + const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id'); + if (!hasGuestIdColumn) { + await knex.schema.alterTable('photo_feedback', (table) => { + table.integer('guest_id').references('id').inTable('gallery_guests').onDelete('SET NULL'); + table.index(['guest_id']); + }); + } +}; + +exports.down = async function(knex) { + const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id'); + if (hasGuestIdColumn) { + await knex.schema.alterTable('photo_feedback', (table) => { + table.dropColumn('guest_id'); + }); + } + + if (knex.client.config.client === 'pg') { + await knex.raw('ALTER TABLE event_feedback_settings DROP CONSTRAINT IF EXISTS event_feedback_settings_identity_mode_check'); + } + const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode'); + if (hasIdentityMode) { + await knex.schema.alterTable('event_feedback_settings', (table) => { + table.dropColumn('identity_mode'); + }); + } + + await knex.schema.dropTableIfExists('guest_verification_codes'); + await knex.schema.dropTableIfExists('guest_invites'); + await knex.schema.dropTableIfExists('gallery_guests'); +}; diff --git a/backend/server.js b/backend/server.js index 5e977a40..5bd6f1b4 100644 --- a/backend/server.js +++ b/backend/server.js @@ -512,12 +512,14 @@ app.use('/api/auth', authRoutes); // Gallery routes - main routes first, then feedback routes app.use('/api/gallery', galleryRoutes); app.use('/api/gallery', require('./src/routes/galleryFeedback')); +app.use('/api/gallery', require('./src/routes/galleryGuests')); app.use('/api/admin', adminRoutes); app.use('/api/admin/auth', adminAuthRoutes); app.use('/api/admin/system', require('./src/routes/adminSystem')); app.use('/api/admin/backup', require('./src/routes/adminBackup')); app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup')); app.use('/api/admin/feedback', require('./src/routes/adminFeedback')); +app.use('/api/admin', require('./src/routes/adminGuests')); app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity')); app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails')); app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions')); diff --git a/backend/src/middleware/feedbackRateLimit.js b/backend/src/middleware/feedbackRateLimit.js index 2f38b74d..1c2bd13b 100644 --- a/backend/src/middleware/feedbackRateLimit.js +++ b/backend/src/middleware/feedbackRateLimit.js @@ -3,9 +3,20 @@ const { db } = require('../database/db'); const logger = require('../utils/logger'); /** - * Generate a unique identifier for the guest + * Generate a unique identifier for the guest. + * + * In guest identity mode, `req.guest.identifier` is a server-issued UUID + * unique per person per event (set by the resolveGuest middleware). When + * present it takes precedence, so rate limits and deduplication become + * per-person instead of per-device. + * + * In simple (legacy) mode, the identifier falls back to a hash of IP + UA, + * matching prior behavior. */ function generateGuestIdentifier(req) { + if (req.guest && req.guest.identifier) { + return req.guest.identifier; + } const ip = req.ip || req.connection.remoteAddress || 'unknown'; const userAgent = req.headers['user-agent'] || 'unknown'; return crypto diff --git a/backend/src/middleware/guestAuth.js b/backend/src/middleware/guestAuth.js new file mode 100644 index 00000000..f50e9d1d --- /dev/null +++ b/backend/src/middleware/guestAuth.js @@ -0,0 +1,105 @@ +const jwt = require('jsonwebtoken'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { getGuestTokenFromRequest } = require('../utils/tokenUtils'); + +/** + * Non-blocking middleware. Reads an optional guest token from the request and, + * if present and valid, populates req.guest with { id, identifier, name, eventId }. + * + * If the token is missing, malformed, or expired → req.guest = null and the + * request continues. Downstream handlers (e.g. feedback submission) enforce + * presence explicitly based on event feedback settings (identity_mode). + */ +async function resolveGuest(req, res, next) { + try { + const slug = req.params?.slug; + const token = getGuestTokenFromRequest(req, slug); + if (!token) { + req.guest = null; + return next(); + } + + let decoded; + try { + const verified = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', + complete: true, + }); + decoded = verified.payload; + } catch (err) { + // Invalid or expired guest tokens are silently ignored so that public + // gallery browsing continues to work even if the token is stale. + logger.debug('Invalid guest token', { reason: err.message }); + req.guest = null; + return next(); + } + + if (decoded.type !== 'guest') { + req.guest = null; + return next(); + } + + // Verify the guest row still exists and is not soft-deleted. + const guest = await db('gallery_guests') + .where({ id: decoded.guestId, event_id: decoded.eventId, is_deleted: false }) + .first(); + + if (!guest) { + req.guest = null; + return next(); + } + + req.guest = { + id: guest.id, + eventId: guest.event_id, + identifier: guest.identifier, + name: guest.name, + email: guest.email || null, + }; + + return next(); + } catch (error) { + logger.error('resolveGuest middleware error', { error: error.message }); + req.guest = null; + return next(); + } +} + +/** + * Blocking middleware that 401s if no guest identity was resolved. + * Use this on endpoints that require a valid guest session. + */ +function requireGuest(req, res, next) { + if (!req.guest) { + return res.status(401).json({ error: 'Guest identity required' }); + } + return next(); +} + +/** + * Sign a new guest JWT. Scoped to a specific event and guest row. + * Expiry matches the gallery token default (24h). + */ +function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') { + return jwt.sign( + { + type: 'guest', + guestId, + eventId, + identifier, + name, + }, + process.env.JWT_SECRET, + { + issuer: 'picpeak-auth', + expiresIn, + } + ); +} + +module.exports = { + resolveGuest, + requireGuest, + signGuestToken, +}; diff --git a/backend/src/routes/adminGuests.js b/backend/src/routes/adminGuests.js new file mode 100644 index 00000000..0cf6eacd --- /dev/null +++ b/backend/src/routes/adminGuests.js @@ -0,0 +1,609 @@ +const express = require('express'); +const crypto = require('crypto'); +const archiver = require('archiver'); +const router = express.Router(); +const { db, logActivity } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { requireEventOwnership } = require('../middleware/ownership'); +const feedbackService = require('../services/feedbackService'); +const logger = require('../utils/logger'); + +const FRONTEND_URL = process.env.FRONTEND_URL || ''; + +// ---------------------------------------------------------------------------- +// Helpers +// ---------------------------------------------------------------------------- + +async function loadGuestOr404(eventId, guestId, res) { + const guest = await db('gallery_guests') + .where({ id: guestId, event_id: eventId, is_deleted: false }) + .first(); + if (!guest) { + res.status(404).json({ error: 'Guest not found' }); + return null; + } + return guest; +} + +function serializeGuest(row) { + return { + id: row.id, + name: row.name, + email: row.email, + created_at: row.created_at, + last_seen_at: row.last_seen_at, + email_verified_at: row.email_verified_at, + is_deleted: row.is_deleted, + }; +} + +function escapeCsvCell(value) { + const str = value == null ? '' : String(value); + if (/[,"\n\r]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests — list guests with aggregated counts +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + + const rows = await db('gallery_guests') + .leftJoin('photo_feedback', function () { + this.on('photo_feedback.guest_id', '=', 'gallery_guests.id'); + }) + .where('gallery_guests.event_id', eventId) + .where('gallery_guests.is_deleted', false) + .groupBy('gallery_guests.id') + .select( + 'gallery_guests.id', + 'gallery_guests.name', + 'gallery_guests.email', + 'gallery_guests.created_at', + 'gallery_guests.last_seen_at', + 'gallery_guests.email_verified_at', + db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"), + db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"), + db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"), + db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"), + db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos') + ) + .orderBy('gallery_guests.created_at', 'desc'); + + const guests = rows.map((r) => ({ + ...serializeGuest(r), + stats: { + likes: parseInt(r.likes, 10) || 0, + favorites: parseInt(r.favorites, 10) || 0, + comments: parseInt(r.comments, 10) || 0, + ratings: parseInt(r.ratings, 10) || 0, + distinct_photos: parseInt(r.distinct_photos, 10) || 0, + }, + })); + + res.json({ guests }); + } catch (error) { + logger.error('Error listing guests:', error); + res.status(500).json({ error: 'Failed to list guests' }); + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/aggregate — photos sorted by distinct +// guest pick count (Phase 2 aggregate view) +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/aggregate', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + + const photos = await db('photos') + .leftJoin('photo_feedback', function () { + this.on('photo_feedback.photo_id', '=', 'photos.id') + .andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')")) + .andOnNotNull('photo_feedback.guest_id'); + }) + .where('photos.event_id', eventId) + .groupBy('photos.id') + .select( + 'photos.id', + 'photos.filename', + 'photos.original_filename', + db.raw('COUNT(DISTINCT photo_feedback.guest_id) AS picker_count') + ) + .orderBy('picker_count', 'desc') + .orderBy('photos.id', 'desc'); + + res.json({ + photos: photos + .filter((p) => parseInt(p.picker_count, 10) > 0) + .map((p) => ({ + id: p.id, + filename: p.filename, + original_filename: p.original_filename, + url: `/admin/photos/${eventId}/photo/${p.id}`, + thumbnail_url: `/admin/photos/${eventId}/thumbnail/${p.id}`, + picker_count: parseInt(p.picker_count, 10), + })), + }); + } catch (error) { + logger.error('Error fetching aggregate view:', error); + res.status(500).json({ error: 'Failed to fetch aggregate view' }); + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/invites — list pre-minted invites +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/invites', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + const event = await db('events').where({ id: eventId }).first(); + + const rows = await db('guest_invites') + .leftJoin('gallery_guests', 'gallery_guests.id', 'guest_invites.guest_id') + .where('guest_invites.event_id', eventId) + .select( + 'guest_invites.id', + 'guest_invites.token', + 'guest_invites.created_at', + 'guest_invites.redeemed_at', + 'guest_invites.revoked_at', + 'gallery_guests.id as guest_id', + 'gallery_guests.name as guest_name', + 'gallery_guests.email as guest_email' + ) + .orderBy('guest_invites.created_at', 'desc'); + + const invites = rows.map((r) => ({ + id: r.id, + token: r.token, + url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`, + created_at: r.created_at, + redeemed_at: r.redeemed_at, + revoked_at: r.revoked_at, + status: r.revoked_at ? 'revoked' : r.redeemed_at ? 'redeemed' : 'pending', + guest: { + id: r.guest_id, + name: r.guest_name, + email: r.guest_email, + }, + })); + + res.json({ invites }); + } catch (error) { + logger.error('Error listing invites:', error); + res.status(500).json({ error: 'Failed to list invites' }); + } + } +); + +// ---------------------------------------------------------------------------- +// POST /admin/events/:eventId/guests/invites — create guest + invite +// Body: { name, email? } +// ---------------------------------------------------------------------------- + +router.post( + '/events/:eventId/guests/invites', + adminAuth, + requirePermission('events.edit'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + const name = String(req.body?.name || '').trim().slice(0, 100); + const email = String(req.body?.email || '').trim().slice(0, 255).toLowerCase(); + if (!name) { + return res.status(400).json({ error: 'Name is required' }); + } + + const identifier = crypto.randomUUID(); + const inviteToken = crypto.randomBytes(24).toString('hex'); + + let guestId; + let inviteId; + await db.transaction(async (trx) => { + const [guestRow] = await trx('gallery_guests') + .insert({ + event_id: eventId, + name, + email: email || null, + identifier, + }) + .returning(['id']); + guestId = guestRow.id; + + const [inviteRow] = await trx('guest_invites') + .insert({ + event_id: eventId, + guest_id: guestId, + token: inviteToken, + created_by_admin_id: req.admin.id, + }) + .returning(['id']); + inviteId = inviteRow.id; + }); + + await logActivity( + 'guest_invite_created', + { event_id: eventId, guest_id: guestId, invite_id: inviteId }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + const event = await db('events').where({ id: eventId }).first(); + res.json({ + invite: { + id: inviteId, + token: inviteToken, + url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`, + status: 'pending', + guest: { id: guestId, name, email: email || null }, + }, + }); + } catch (error) { + logger.error('Error creating invite:', error); + res.status(500).json({ error: 'Failed to create invite' }); + } + } +); + +// ---------------------------------------------------------------------------- +// DELETE /admin/events/:eventId/guests/invites/:inviteId — revoke +// ---------------------------------------------------------------------------- + +router.delete( + '/events/:eventId/guests/invites/:inviteId', + adminAuth, + requirePermission('events.edit'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, inviteId } = req.params; + const updated = await db('guest_invites') + .where({ id: inviteId, event_id: eventId }) + .whereNull('revoked_at') + .update({ revoked_at: db.fn.now() }); + + if (!updated) { + return res.status(404).json({ error: 'Invite not found or already revoked' }); + } + + await logActivity( + 'guest_invite_revoked', + { event_id: eventId, invite_id: inviteId }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ success: true }); + } catch (error) { + logger.error('Error revoking invite:', error); + res.status(500).json({ error: 'Failed to revoke invite' }); + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/export-all — ZIP of per-guest exports +// Query: format=txt|csv|json (default: csv) +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/export-all', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId } = req.params; + const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'csv'; + + const guests = await db('gallery_guests') + .where({ event_id: eventId, is_deleted: false }) + .select('id', 'name', 'email'); + + if (guests.length === 0) { + return res.status(404).json({ error: 'No guests to export' }); + } + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="event-${eventId}-guests.zip"` + ); + + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.on('error', (err) => { + logger.error('Archive error:', err); + res.status(500).end(); + }); + archive.pipe(res); + + for (const g of guests) { + const selections = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.guest_id', g.id) + .whereIn('photo_feedback.feedback_type', ['like', 'favorite']) + .select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type'); + + const safeName = g.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${g.id}`; + const filename = `${safeName}.${format}`; + + let body; + if (format === 'json') { + body = JSON.stringify({ guest: g, selections }, null, 2); + } else if (format === 'csv') { + const header = 'filename,original_filename,feedback_type'; + const rows = selections.map( + (s) => + `${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}` + ); + body = [header, ...rows].join('\n'); + } else { + // txt — just filenames + body = selections.map((s) => s.original_filename || s.filename).join('\n'); + } + archive.append(body, { name: filename }); + } + + await archive.finalize(); + } catch (error) { + logger.error('Error exporting all guests:', error); + if (!res.headersSent) { + res.status(500).json({ error: 'Failed to export guests' }); + } + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/:guestId — guest detail with selections +// (Phase 2) +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/:guestId', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, guestId } = req.params; + const guest = await loadGuestOr404(eventId, guestId, res); + if (!guest) return; + + const feedback = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.guest_id', guestId) + .select( + 'photo_feedback.id as feedback_id', + 'photo_feedback.feedback_type', + 'photo_feedback.rating', + 'photo_feedback.comment_text', + 'photo_feedback.created_at', + 'photos.id as photo_id', + 'photos.filename', + 'photos.original_filename', + 'photos.type' + ) + .orderBy('photo_feedback.created_at', 'desc'); + + const photoFor = (row) => ({ + id: row.photo_id, + filename: row.filename, + original_filename: row.original_filename, + type: row.type, + url: `/admin/photos/${eventId}/photo/${row.photo_id}`, + thumbnail_url: `/admin/photos/${eventId}/thumbnail/${row.photo_id}`, + }); + + const selections = { + liked: [], + favorited: [], + rated: [], + commented: [], + }; + for (const row of feedback) { + if (row.feedback_type === 'like') { + selections.liked.push(photoFor(row)); + } else if (row.feedback_type === 'favorite') { + selections.favorited.push(photoFor(row)); + } else if (row.feedback_type === 'rating') { + selections.rated.push({ photo: photoFor(row), rating: row.rating }); + } else if (row.feedback_type === 'comment') { + selections.commented.push({ + photo: photoFor(row), + comment: row.comment_text, + created_at: row.created_at, + }); + } + } + + res.json({ + guest: { + ...serializeGuest(guest), + stats: { + likes: selections.liked.length, + favorites: selections.favorited.length, + comments: selections.commented.length, + ratings: selections.rated.length, + }, + }, + selections, + }); + } catch (error) { + logger.error('Error fetching guest detail:', error); + res.status(500).json({ error: 'Failed to fetch guest detail' }); + } + } +); + +// ---------------------------------------------------------------------------- +// GET /admin/events/:eventId/guests/:guestId/export — per-guest export +// Query: format=txt|csv|json +// ---------------------------------------------------------------------------- + +router.get( + '/events/:eventId/guests/:guestId/export', + adminAuth, + requirePermission('events.view'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, guestId } = req.params; + const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'txt'; + const guest = await loadGuestOr404(eventId, guestId, res); + if (!guest) return; + + const selections = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.guest_id', guestId) + .whereIn('photo_feedback.feedback_type', ['like', 'favorite']) + .select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type'); + + const safeName = guest.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${guest.id}`; + const filename = `${safeName}.${format}`; + + if (format === 'json') { + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(JSON.stringify({ guest: serializeGuest(guest), selections }, null, 2)); + } + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + const header = 'filename,original_filename,feedback_type'; + const rows = selections.map( + (s) => + `${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}` + ); + return res.send([header, ...rows].join('\n')); + } + // txt — one filename per line + res.setHeader('Content-Type', 'text/plain'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + return res.send(selections.map((s) => s.original_filename || s.filename).join('\n')); + } catch (error) { + logger.error('Error exporting guest:', error); + res.status(500).json({ error: 'Failed to export guest' }); + } + } +); + +// ---------------------------------------------------------------------------- +// DELETE /admin/events/:eventId/guests/:guestId — anonymize (soft delete) +// ---------------------------------------------------------------------------- + +router.delete( + '/events/:eventId/guests/:guestId', + adminAuth, + requirePermission('events.edit'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, guestId } = req.params; + const guest = await loadGuestOr404(eventId, guestId, res); + if (!guest) return; + + const result = await feedbackService.anonymizeGuestFeedback(guestId); + + await db('gallery_guests').where({ id: guestId }).update({ + is_deleted: true, + name: 'Removed', + email: null, + last_seen_at: db.fn.now(), + }); + + await logActivity( + 'guest_deleted', + { event_id: eventId, guest_id: guestId, anonymized: result.anonymized }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ success: true, ...result }); + } catch (error) { + logger.error('Error deleting guest:', error); + res.status(500).json({ error: 'Failed to delete guest' }); + } + } +); + +// ---------------------------------------------------------------------------- +// POST /admin/events/:eventId/guests/:keepId/merge — merge guests (Phase 3.4) +// Body: { mergeIds: number[] } +// ---------------------------------------------------------------------------- + +router.post( + '/events/:eventId/guests/:keepId/merge', + adminAuth, + requirePermission('events.edit'), + requireEventOwnership, + async (req, res) => { + try { + const { eventId, keepId } = req.params; + const mergeIds = Array.isArray(req.body?.mergeIds) ? req.body.mergeIds : []; + + if (mergeIds.length === 0) { + return res.status(400).json({ error: 'mergeIds is required' }); + } + if (mergeIds.includes(Number(keepId))) { + return res.status(400).json({ error: 'Cannot merge a guest into itself' }); + } + + // Sanity check: all guests belong to this event. + const all = await db('gallery_guests') + .whereIn('id', [Number(keepId), ...mergeIds.map(Number)]) + .where({ event_id: eventId }); + if (all.length !== mergeIds.length + 1) { + return res.status(400).json({ error: 'All guests must belong to the same event' }); + } + + const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number)); + + // Soft-delete the merged (source) guests. + await db('gallery_guests') + .whereIn('id', mergeIds.map(Number)) + .update({ is_deleted: true, last_seen_at: db.fn.now() }); + + await logActivity( + 'guest_merged', + { event_id: eventId, keep_id: keepId, merged_ids: mergeIds }, + eventId, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ success: true, ...result }); + } catch (error) { + logger.error('Error merging guests:', error); + res.status(500).json({ error: 'Failed to merge guests' }); + } + } +); + +module.exports = router; diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index ebaa35c2..716b085a 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -1199,10 +1199,12 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => res.json({ feedback_enabled: settings.feedback_enabled || false, allow_ratings: settings.allow_ratings, - allow_likes: settings.allow_likes, + allow_likes: settings.allow_likes, allow_comments: settings.allow_comments, allow_favorites: settings.allow_favorites, - show_feedback_to_guests: settings.show_feedback_to_guests + show_feedback_to_guests: settings.show_feedback_to_guests, + require_name_email: settings.require_name_email || false, + identity_mode: settings.identity_mode || 'simple' }); } catch (error) { console.error('Error fetching feedback settings:', error); diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js index a65ef327..b8028509 100644 --- a/backend/src/routes/galleryFeedback.js +++ b/backend/src/routes/galleryFeedback.js @@ -3,6 +3,7 @@ const router = express.Router(); const { photoAuth } = require('../middleware/photoAuth'); const { verifyGalleryAccess } = require('../middleware/gallery'); const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); +const { resolveGuest } = require('../middleware/guestAuth'); const feedbackService = require('../services/feedbackService'); const feedbackModeration = require('../services/feedbackModeration'); const { db, logActivity } = require('../database/db'); @@ -22,7 +23,7 @@ router.get('/:slug/feedback-settings', try { const event = req.event; const settings = await feedbackService.getEventFeedbackSettings(event.id); - + // Only send relevant settings to guests // Convert SQLite boolean values (0/1) to proper booleans const guestSettings = { @@ -32,9 +33,10 @@ router.get('/:slug/feedback-settings', allow_comments: Boolean(settings.allow_comments), allow_favorites: Boolean(settings.allow_favorites), require_name_email: Boolean(settings.require_name_email), - show_feedback_to_guests: Boolean(settings.show_feedback_to_guests) + show_feedback_to_guests: Boolean(settings.show_feedback_to_guests), + identity_mode: settings.identity_mode || 'simple' }; - + res.json(guestSettings); } catch (error) { logger.error('Error getting feedback settings:', error); @@ -46,6 +48,7 @@ router.get('/:slug/feedback-settings', // Get feedback for a specific photo router.get('/:slug/photos/:photoId/feedback', verifyGalleryAccess, + resolveGuest, validatePhotoId, checkValidation, async (req, res) => { @@ -137,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback', // Submit feedback for a photo router.post('/:slug/photos/:photoId/feedback', verifyGalleryAccess, + resolveGuest, validatePhotoId, validateFeedbackSubmission, checkValidation, @@ -144,15 +148,28 @@ router.post('/:slug/photos/:photoId/feedback', try { const { photoId } = req.params; const event = req.event; - const guestIdentifier = generateGuestIdentifier(req); - - // Get feedback settings + + // Get feedback settings first so we can enforce identity_mode. const settings = await feedbackService.getEventFeedbackSettings(event.id); - + if (!settings.feedback_enabled) { return res.status(403).json({ error: 'Feedback is not enabled for this event' }); } - + + // In guest identity mode, a valid guest token is required. The server + // never trusts guest_name/guest_email from the body in this mode — it + // reads them from the verified token via req.guest. + if (settings.identity_mode === 'guest') { + if (!req.guest || req.guest.eventId !== event.id) { + return res.status(401).json({ + error: 'Guest identity required', + code: 'GUEST_IDENTITY_REQUIRED' + }); + } + } + + const guestIdentifier = generateGuestIdentifier(req); + // Check if specific feedback type is allowed const feedbackType = req.body.feedback_type; const typeAllowed = { @@ -161,29 +178,32 @@ router.post('/:slug/photos/:photoId/feedback', comment: settings.allow_comments, favorite: settings.allow_favorites }; - + if (!typeAllowed[feedbackType]) { return res.status(403).json({ error: `${feedbackType} feedback is not enabled` }); } - + // Verify photo belongs to event const photo = await db('photos') .where({ id: photoId, event_id: event.id }) .first(); - + if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } - - // Validate guest requirements - const guestValidation = await validateGuestRequirements(settings, req.body); - if (!guestValidation.valid) { - return res.status(400).json({ - error: 'Guest information required', - errors: guestValidation.errors - }); + + // Validate guest requirements only in simple mode. In guest mode, the + // identity is already provided via the token and verified above. + if (settings.identity_mode !== 'guest') { + const guestValidation = await validateGuestRequirements(settings, req.body); + if (!guestValidation.valid) { + return res.status(400).json({ + error: 'Guest information required', + errors: guestValidation.errors + }); + } } - + // Apply rate limiting based on feedback type const rateLimitMiddleware = feedbackRateLimit(feedbackType); await new Promise((resolve, reject) => { @@ -192,17 +212,19 @@ router.post('/:slug/photos/:photoId/feedback', else resolve(); }); }); - + // If we got here and response was sent (rate limited), return if (res.headersSent) return; - - // Prepare feedback data + + // Prepare feedback data. In guest mode, use the verified token as the + // source of truth for name/email — never the body. const feedbackData = { feedback_type: feedbackType, rating: req.body.rating, comment_text: req.body.comment_text, - guest_name: req.body.guest_name, - guest_email: req.body.guest_email, + guest_name: req.guest?.name ?? req.body.guest_name, + guest_email: req.guest?.email ?? req.body.guest_email, + guest_id: req.guest?.id ?? null, ip_address: req.ip || req.connection.remoteAddress, user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255), moderate_comments: settings.moderate_comments @@ -316,22 +338,32 @@ router.get('/:slug/feedback-summary', // Get user's own feedback for all photos router.get('/:slug/my-feedback', verifyGalleryAccess, + resolveGuest, async (req, res) => { try { const event = req.event; - const guestIdentifier = generateGuestIdentifier(req); - - const myFeedback = await db('photo_feedback') + + const query = db('photo_feedback') .join('photos', 'photo_feedback.photo_id', 'photos.id') - .where('photo_feedback.event_id', event.id) - .where('photo_feedback.guest_identifier', guestIdentifier) + .where('photo_feedback.event_id', event.id); + + // Prefer guest_id lookup when a verified guest token is present + // (per-person identity). Fall back to the device hash otherwise. + if (req.guest?.id) { + query.where('photo_feedback.guest_id', req.guest.id); + } else { + const guestIdentifier = generateGuestIdentifier(req); + query.where('photo_feedback.guest_identifier', guestIdentifier); + } + + const myFeedback = await query .select( 'photo_feedback.*', 'photos.filename', 'photos.path' ) .orderBy('photo_feedback.created_at', 'desc'); - + res.json(myFeedback); } catch (error) { logger.error('Error getting user feedback:', error); diff --git a/backend/src/routes/galleryGuests.js b/backend/src/routes/galleryGuests.js new file mode 100644 index 00000000..2be4b5e8 --- /dev/null +++ b/backend/src/routes/galleryGuests.js @@ -0,0 +1,409 @@ +const express = require('express'); +const crypto = require('crypto'); +const router = express.Router(); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { verifyGalleryAccess } = require('../middleware/gallery'); +const { resolveGuest, requireGuest, signGuestToken } = require('../middleware/guestAuth'); +const feedbackService = require('../services/feedbackService'); +const guestRecovery = require('../services/guestRecoveryService'); + +const MAX_NAME_LEN = 100; +const MAX_EMAIL_LEN = 255; +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// In-memory rate limit for guest registration (20 per hour per IP). Simple +// sliding window; on process restart the counters reset which is acceptable. +const registrationAttempts = new Map(); +const REGISTRATION_WINDOW_MS = 60 * 60 * 1000; +const REGISTRATION_MAX = 20; + +function checkRegistrationRate(ip) { + const now = Date.now(); + const entry = registrationAttempts.get(ip) || { count: 0, windowStart: now }; + if (now - entry.windowStart > REGISTRATION_WINDOW_MS) { + entry.count = 0; + entry.windowStart = now; + } + entry.count += 1; + registrationAttempts.set(ip, entry); + return entry.count <= REGISTRATION_MAX; +} + +function sanitizeName(value) { + if (typeof value !== 'string') return ''; + // Strip HTML/control chars, collapse whitespace. + const cleaned = value + .replace(/[<>&"']/g, '') + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return cleaned.slice(0, MAX_NAME_LEN); +} + +function sanitizeEmail(value) { + if (typeof value !== 'string') return ''; + return value.trim().slice(0, MAX_EMAIL_LEN).toLowerCase(); +} + +/** + * POST /gallery/:slug/guest + * Body: { name, email? } + * + * Registers a new per-person guest identity for this gallery. Returns a JWT + * that the frontend must send as the x-guest-token header on subsequent + * feedback requests. + */ +router.post('/:slug/guest', verifyGalleryAccess, async (req, res) => { + try { + const ip = req.ip || req.connection.remoteAddress || 'unknown'; + if (!checkRegistrationRate(ip)) { + return res.status(429).json({ error: 'Too many registration attempts' }); + } + + const event = req.event; + const settings = await feedbackService.getEventFeedbackSettings(event.id); + + // Guest registration is only meaningful when feedback is enabled. + if (!settings.feedback_enabled) { + return res.status(403).json({ error: 'Feedback is not enabled for this gallery' }); + } + + const name = sanitizeName(req.body?.name); + if (!name || name.length < 1) { + return res.status(400).json({ error: 'Name is required', field: 'name' }); + } + + let email = sanitizeEmail(req.body?.email); + if (email && !EMAIL_REGEX.test(email)) { + return res.status(400).json({ error: 'Invalid email format', field: 'email' }); + } + if (settings.require_name_email && !email) { + return res.status(400).json({ error: 'Email is required', field: 'email' }); + } + + const identifier = crypto.randomUUID(); + const userAgent = (req.headers['user-agent'] || '').substring(0, 500); + + const [row] = await db('gallery_guests') + .insert({ + event_id: event.id, + name, + email: email || null, + identifier, + ip_address_last: ip.substring(0, 45), + user_agent_last: userAgent, + }) + .returning(['id', 'name', 'email', 'identifier', 'created_at']); + + const token = signGuestToken({ + guestId: row.id, + eventId: event.id, + identifier: row.identifier, + name: row.name, + }); + + logger.info('Guest registered', { + eventId: event.id, + guestId: row.id, + name: row.name, + }); + + return res.json({ + guest: { + id: row.id, + name: row.name, + email: row.email, + identifier: row.identifier, + }, + token, + }); + } catch (error) { + logger.error('Guest registration failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to register guest' }); + } +}); + +/** + * GET /gallery/:slug/guest/me + * Returns the current guest profile from a valid guest token. 401 otherwise. + */ +router.get('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => { + try { + if (req.guest.eventId !== req.event.id) { + return res.status(403).json({ error: 'Guest token does not match gallery' }); + } + + // Update last_seen_at on each profile fetch (cheap and useful for admin). + await db('gallery_guests') + .where({ id: req.guest.id }) + .update({ + last_seen_at: db.fn.now(), + ip_address_last: (req.ip || '').substring(0, 45), + user_agent_last: (req.headers['user-agent'] || '').substring(0, 500), + }); + + return res.json({ + guest: { + id: req.guest.id, + name: req.guest.name, + email: req.guest.email, + identifier: req.guest.identifier, + }, + }); + } catch (error) { + logger.error('Guest profile fetch failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to fetch guest profile' }); + } +}); + +/** + * DELETE /gallery/:slug/guest/me + * + * "Forget me" — soft-deletes the guest row and anonymizes their feedback so + * aggregate counts remain stable but personal data is removed. + */ +router.delete('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => { + try { + if (req.guest.eventId !== req.event.id) { + return res.status(403).json({ error: 'Guest token does not match gallery' }); + } + + await feedbackService.anonymizeGuestFeedback(req.guest.id); + + await db('gallery_guests') + .where({ id: req.guest.id }) + .update({ + is_deleted: true, + name: 'Removed', + email: null, + last_seen_at: db.fn.now(), + }); + + logger.info('Guest self-forgot', { + eventId: req.event.id, + guestId: req.guest.id, + }); + + return res.json({ success: true }); + } catch (error) { + logger.error('Guest forget-me failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to forget guest' }); + } +}); + +// --------------------------------------------------------------------------- +// Phase 3.2 — Email-based identity recovery +// --------------------------------------------------------------------------- + +// Simple in-memory rate limit for recover/verify (5 per hour per IP). +const recoveryAttempts = new Map(); +const VERIFY_WINDOW_MS = 60 * 60 * 1000; +const VERIFY_MAX = 20; +function checkRecoveryRate(ip) { + const now = Date.now(); + const entry = recoveryAttempts.get(ip) || { count: 0, windowStart: now }; + if (now - entry.windowStart > VERIFY_WINDOW_MS) { + entry.count = 0; + entry.windowStart = now; + } + entry.count += 1; + recoveryAttempts.set(ip, entry); + return entry.count <= VERIFY_MAX; +} + +/** + * POST /gallery/:slug/guest/recover + * Body: { email } + * + * Sends a 6-digit code to the email if it matches an existing guest. Returns + * 200 regardless of whether a matching guest exists (prevents enumeration). + */ +router.post('/:slug/guest/recover', verifyGalleryAccess, async (req, res) => { + try { + const ip = req.ip || 'unknown'; + if (!checkRecoveryRate(ip)) { + return res.status(429).json({ error: 'Too many recovery attempts' }); + } + + const email = sanitizeEmail(req.body?.email); + if (!email || !EMAIL_REGEX.test(email)) { + // Still return 200 to avoid leaking validity of the email field. + return res.json({ success: true }); + } + + const event = req.event; + const settings = await feedbackService.getEventFeedbackSettings(event.id); + if (!settings.feedback_enabled || settings.identity_mode !== 'guest') { + return res.json({ success: true }); + } + + const guest = await db('gallery_guests') + .where({ event_id: event.id, email, is_deleted: false }) + .first(); + + if (guest) { + try { + const code = await guestRecovery.createCode(event.id, email); + await guestRecovery.sendRecoveryEmail(email, code, event.event_name || 'your gallery'); + } catch (sendError) { + logger.error('Failed to send recovery email', { error: sendError.message }); + // Still return 200 so clients can't distinguish failures. + } + } + + return res.json({ success: true }); + } catch (error) { + logger.error('Guest recovery request failed', { error: error.message }); + return res.json({ success: true }); + } +}); + +/** + * POST /gallery/:slug/guest/verify + * Body: { email, code } + * + * Exchanges a valid verification code for a guest token. Reuses the existing + * guest row associated with the email (the guest continues where they left + * off, cross-device). + */ +router.post('/:slug/guest/verify', verifyGalleryAccess, async (req, res) => { + try { + const ip = req.ip || 'unknown'; + if (!checkRecoveryRate(ip)) { + return res.status(429).json({ error: 'Too many verification attempts' }); + } + + const email = sanitizeEmail(req.body?.email); + const code = String(req.body?.code || '').trim(); + if (!email || !code) { + return res.status(400).json({ error: 'Email and code are required' }); + } + + const event = req.event; + const verifyResult = await guestRecovery.verifyCode(event.id, email, code); + if (!verifyResult.ok) { + return res.status(401).json({ error: 'Invalid or expired code', reason: verifyResult.reason }); + } + + const guest = await db('gallery_guests') + .where({ event_id: event.id, email, is_deleted: false }) + .first(); + if (!guest) { + return res.status(404).json({ error: 'Guest not found' }); + } + + await db('gallery_guests') + .where({ id: guest.id }) + .update({ + email_verified_at: guest.email_verified_at || db.fn.now(), + last_seen_at: db.fn.now(), + ip_address_last: (req.ip || '').substring(0, 45), + }); + + const token = signGuestToken({ + guestId: guest.id, + eventId: event.id, + identifier: guest.identifier, + name: guest.name, + }); + + logger.info('Guest recovered via email', { eventId: event.id, guestId: guest.id }); + + return res.json({ + guest: { + id: guest.id, + name: guest.name, + email: guest.email, + identifier: guest.identifier, + }, + token, + }); + } catch (error) { + logger.error('Guest verify failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to verify code' }); + } +}); + +// --------------------------------------------------------------------------- +// Phase 3.3 — Invite token redemption +// --------------------------------------------------------------------------- + +/** + * POST /gallery/:slug/guest/redeem + * Body: { inviteToken } + * + * Redeems a pre-minted invite token (created by admin). Single use. + */ +router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => { + try { + const inviteToken = String(req.body?.inviteToken || '').trim(); + if (!inviteToken) { + return res.status(400).json({ error: 'Invite token required' }); + } + + const event = req.event; + + const result = await db.transaction(async (trx) => { + const invite = await trx('guest_invites') + .where({ token: inviteToken, event_id: event.id }) + .first(); + if (!invite) return { error: 'not_found' }; + if (invite.revoked_at) return { error: 'revoked' }; + if (invite.redeemed_at) return { error: 'already_redeemed' }; + + const guest = await trx('gallery_guests') + .where({ id: invite.guest_id, is_deleted: false }) + .first(); + if (!guest) return { error: 'guest_missing' }; + + await trx('guest_invites') + .where({ id: invite.id }) + .update({ redeemed_at: trx.fn.now() }); + + await trx('gallery_guests') + .where({ id: guest.id }) + .update({ + last_seen_at: trx.fn.now(), + ip_address_last: (req.ip || '').substring(0, 45), + user_agent_last: (req.headers['user-agent'] || '').substring(0, 500), + }); + + return { guest }; + }); + + if (result.error) { + const statusMap = { + not_found: 404, + revoked: 410, + already_redeemed: 409, + guest_missing: 404, + }; + return res.status(statusMap[result.error] || 400).json({ error: result.error }); + } + + const token = signGuestToken({ + guestId: result.guest.id, + eventId: event.id, + identifier: result.guest.identifier, + name: result.guest.name, + }); + + logger.info('Invite redeemed', { eventId: event.id, guestId: result.guest.id }); + + return res.json({ + guest: { + id: result.guest.id, + name: result.guest.name, + email: result.guest.email, + identifier: result.guest.identifier, + }, + token, + }); + } catch (error) { + logger.error('Invite redemption failed', { error: error.message }); + return res.status(500).json({ error: 'Failed to redeem invite' }); + } +}); + +module.exports = router; diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js index e254b430..0e8c3a05 100644 --- a/backend/src/services/feedbackService.js +++ b/backend/src/services/feedbackService.js @@ -23,10 +23,15 @@ class FeedbackService { allow_favorites: true, require_name_email: false, moderate_comments: true, - show_feedback_to_guests: true + show_feedback_to_guests: true, + identity_mode: 'simple' }; } - + + // Back-compat: rows created before migration 078 have NULL identity_mode. + if (!settings.identity_mode) { + settings.identity_mode = 'simple'; + } return settings; } catch (error) { logger.error('Error getting feedback settings:', error); @@ -73,23 +78,29 @@ class FeedbackService { */ async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) { try { - const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData; + const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData; // Validate feedback type if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) { throw new Error('Invalid feedback type'); } - // Check if similar feedback already exists (prevent duplicates) + // Check if similar feedback already exists (prevent duplicates). + // When a per-person guest_id is present, scope the check to that guest + // so two guests on the same device can independently like a photo. if (feedback_type !== 'comment') { - const existing = await db('photo_feedback') + const duplicateQuery = db('photo_feedback') .where({ photo_id: photoId, event_id: eventId, feedback_type, - guest_identifier: guestIdentifier - }) - .first(); + }); + if (guest_id) { + duplicateQuery.where('guest_id', guest_id); + } else { + duplicateQuery.where('guest_identifier', guestIdentifier); + } + const existing = await duplicateQuery.first(); if (existing) { if (feedback_type === 'rating' && rating !== existing.rating) { @@ -129,6 +140,7 @@ class FeedbackService { guest_name, guest_email, guest_identifier: guestIdentifier, + guest_id: guest_id || null, ip_address, user_agent, is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments, @@ -232,7 +244,7 @@ class FeedbackService { db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']), db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']), db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']), - db.raw('COUNT(DISTINCT guest_identifier) as feedback_count') + db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count') ) .first(); @@ -460,6 +472,75 @@ class FeedbackService { throw error; } } + + /** + * Anonymize feedback belonging to a guest — sets guest_id to NULL on all + * their feedback rows and clears guest_name/guest_email for privacy, then + * recomputes denormalized photo counts on affected photos. + * + * Used by self-service "forget me" and admin guest deletion. + */ + async anonymizeGuestFeedback(guestId) { + try { + const affected = await db('photo_feedback') + .where('guest_id', guestId) + .select('photo_id'); + const photoIds = [...new Set(affected.map((r) => r.photo_id))]; + + await db('photo_feedback') + .where('guest_id', guestId) + .update({ + guest_id: null, + guest_name: null, + guest_email: null, + updated_at: new Date(), + }); + + for (const pid of photoIds) { + await this.updatePhotoFeedbackStats(pid); + } + + return { anonymized: affected.length, photos: photoIds.length }; + } catch (error) { + logger.error('Error anonymizing guest feedback:', error); + throw error; + } + } + + /** + * Merge feedback rows from sourceGuestIds into keepGuestId. Used by admin + * guest merge and email-based identity recovery when a user re-registers. + * Recomputes denormalized counts on affected photos. + */ + async mergeGuestFeedback(keepGuestId, sourceGuestIds) { + try { + const sources = (sourceGuestIds || []).filter((id) => id && id !== keepGuestId); + if (sources.length === 0) { + return { merged: 0, photos: 0 }; + } + + const affected = await db('photo_feedback') + .whereIn('guest_id', sources) + .select('photo_id'); + const photoIds = [...new Set(affected.map((r) => r.photo_id))]; + + await db('photo_feedback') + .whereIn('guest_id', sources) + .update({ + guest_id: keepGuestId, + updated_at: new Date(), + }); + + for (const pid of photoIds) { + await this.updatePhotoFeedbackStats(pid); + } + + return { merged: affected.length, photos: photoIds.length }; + } catch (error) { + logger.error('Error merging guest feedback:', error); + throw error; + } + } } module.exports = new FeedbackService(); \ No newline at end of file diff --git a/backend/src/services/guestRecoveryService.js b/backend/src/services/guestRecoveryService.js new file mode 100644 index 00000000..971783ce --- /dev/null +++ b/backend/src/services/guestRecoveryService.js @@ -0,0 +1,131 @@ +/** + * Guest identity recovery service (Phase 3.2). + * + * Sends a short-lived 6-digit verification code to a guest's email address + * so they can re-link their identity across devices. The code is stored as + * a bcrypt hash in `guest_verification_codes` with a 15-minute expiry. + * + * Uses the email transporter from emailProcessor — no new template row is + * needed; the email body is built inline so this works out of the box. + */ + +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { initializeTransporter, wrapEmailHtml } = require('./emailProcessor'); + +const CODE_TTL_MS = 15 * 60 * 1000; +const MAX_ATTEMPTS = 5; + +function generateCode() { + // 6 digits, zero-padded. + return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0'); +} + +async function createCode(eventId, email) { + const code = generateCode(); + const codeHash = await bcrypt.hash(code, 10); + const expiresAt = new Date(Date.now() + CODE_TTL_MS); + + // Invalidate any previous unconsumed codes for this email+event. + await db('guest_verification_codes') + .where({ event_id: eventId, email: email.toLowerCase() }) + .whereNull('consumed_at') + .update({ consumed_at: db.fn.now() }); + + await db('guest_verification_codes').insert({ + event_id: eventId, + email: email.toLowerCase(), + code_hash: codeHash, + expires_at: expiresAt, + }); + + return code; +} + +async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') { + const transporter = await initializeTransporter(); + if (!transporter) { + throw new Error('Email service not configured'); + } + + const config = await db('email_configs').first(); + if (!config) { + throw new Error('Email configuration not found'); + } + + const subject = `Your verification code: ${code}`; + const htmlBody = ` +
+

Welcome back to ${eventName}

+

Enter this code to recover your picks in the gallery:

+
+ ${code} +
+

+ This code expires in 15 minutes. If you did not request it, you can safely ignore this email. +

+
+ `; + const styledHtml = await wrapEmailHtml(htmlBody, subject, 'en'); + + await transporter.sendMail({ + from: `${config.from_name} <${config.from_email}>`, + to: toEmail, + subject, + html: styledHtml, + text: `Your verification code is ${code}. It expires in 15 minutes.`, + }); + + logger.info('Guest recovery code sent', { email: toEmail }); +} + +/** + * Verify a code. Returns true if valid + marks it consumed. + * Increments attempts on failure. Rejects after MAX_ATTEMPTS. + */ +async function verifyCode(eventId, email, submittedCode) { + const normalized = String(submittedCode || '').trim(); + if (!/^\d{6}$/.test(normalized)) { + return { ok: false, reason: 'invalid_format' }; + } + + const row = await db('guest_verification_codes') + .where({ event_id: eventId, email: email.toLowerCase() }) + .whereNull('consumed_at') + .andWhere('expires_at', '>', new Date()) + .orderBy('created_at', 'desc') + .first(); + + if (!row) { + return { ok: false, reason: 'expired_or_missing' }; + } + + if (row.attempts >= MAX_ATTEMPTS) { + await db('guest_verification_codes').where('id', row.id).update({ consumed_at: db.fn.now() }); + return { ok: false, reason: 'too_many_attempts' }; + } + + const matches = await bcrypt.compare(normalized, row.code_hash); + if (!matches) { + await db('guest_verification_codes') + .where('id', row.id) + .update({ attempts: row.attempts + 1 }); + return { ok: false, reason: 'wrong_code' }; + } + + await db('guest_verification_codes') + .where('id', row.id) + .update({ consumed_at: db.fn.now() }); + + return { ok: true }; +} + +module.exports = { + createCode, + sendRecoveryEmail, + verifyCode, + CODE_TTL_MS, + MAX_ATTEMPTS, +}; diff --git a/backend/src/utils/feedbackValidation.js b/backend/src/utils/feedbackValidation.js index 2c7dbdb4..84d004de 100644 --- a/backend/src/utils/feedbackValidation.js +++ b/backend/src/utils/feedbackValidation.js @@ -184,7 +184,9 @@ const validateFeedbackSettings = [ body('allow_favorites').optional().isBoolean(), body('require_name_email').optional().isBoolean(), body('moderate_comments').optional().isBoolean(), - body('show_feedback_to_guests').optional().isBoolean() + body('show_feedback_to_guests').optional().isBoolean(), + body('identity_mode').optional().isIn(['simple', 'guest']) + .withMessage('identity_mode must be "simple" or "guest"') ]; /** diff --git a/backend/src/utils/tokenUtils.js b/backend/src/utils/tokenUtils.js index c70ad92b..a664d1be 100644 --- a/backend/src/utils/tokenUtils.js +++ b/backend/src/utils/tokenUtils.js @@ -1,6 +1,7 @@ const ADMIN_COOKIE_NAME = 'admin_token'; const GALLERY_COOKIE_NAME = 'gallery_token'; const GALLERY_COOKIE_PREFIX = 'gallery_token_'; +const GUEST_COOKIE_PREFIX = 'guest_token_'; const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -114,10 +115,37 @@ function getGalleryTokenFromRequest(req, slug) { return null; } +function getGuestTokenFromRequest(req, slug) { + // Primary transport: custom header (set by frontend axios interceptor). + const headerToken = req.headers?.['x-guest-token']; + if (headerToken) { + return headerToken; + } + + if (!req.cookies) { + return null; + } + + if (slug) { + const cookieName = `${GUEST_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`; + if (req.cookies[cookieName]) { + return req.cookies[cookieName]; + } + } + + const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GUEST_COOKIE_PREFIX)); + if (prefixed) { + return req.cookies[prefixed]; + } + + return null; +} + module.exports = { ADMIN_COOKIE_NAME, GALLERY_COOKIE_NAME, GALLERY_COOKIE_PREFIX, + GUEST_COOKIE_PREFIX, sanitizeSlugForCookie, setAdminAuthCookie, clearAdminAuthCookie, @@ -125,4 +153,5 @@ module.exports = { clearGalleryAuthCookies, getAdminTokenFromRequest, getGalleryTokenFromRequest, + getGuestTokenFromRequest, }; diff --git a/frontend/src/components/admin/AdminGuestDetail.tsx b/frontend/src/components/admin/AdminGuestDetail.tsx new file mode 100644 index 00000000..0c1964f9 --- /dev/null +++ b/frontend/src/components/admin/AdminGuestDetail.tsx @@ -0,0 +1,205 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react'; +import { Loading } from '../common'; +import { guestsService, AdminGuest } from '../../services/guests.service'; +import { AuthenticatedImage } from '../common/AuthenticatedImage'; +import { buildResourceUrl } from '../../utils/url'; + +interface AdminGuestDetailProps { + eventId: number; + guest: AdminGuest; + onClose: () => void; +} + +type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented'; + +export const AdminGuestDetail: React.FC = ({ eventId, guest, onClose }) => { + const { t } = useTranslation(); + const [tab, setTab] = useState('all'); + + const { data, isLoading } = useQuery({ + queryKey: ['admin-guest-detail', eventId, guest.id], + queryFn: () => guestsService.getGuestDetail(eventId, guest.id), + }); + + const selections = data?.selections; + const liked = selections?.liked || []; + const favorited = selections?.favorited || []; + const rated = selections?.rated || []; + const commented = selections?.commented || []; + + // "all" view combines the three visual selection types. + type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] }; + const allItems: GridItem[] = []; + const seen = new Map(); + const add = (photo: { id: number; filename: string; thumbnail_url: string }, badge: string) => { + if (!seen.has(photo.id)) { + const item: GridItem = { photo, badges: [badge] }; + seen.set(photo.id, item); + allItems.push(item); + } else { + seen.get(photo.id)!.badges.push(badge); + } + }; + liked.forEach((p) => add(p, 'like')); + favorited.forEach((p) => add(p, 'favorite')); + rated.forEach((r) => add(r.photo, 'rating')); + + const visibleItems: GridItem[] = + tab === 'all' + ? allItems + : tab === 'liked' + ? liked.map((p) => ({ photo: p, badges: ['like'] })) + : tab === 'favorited' + ? favorited.map((p) => ({ photo: p, badges: ['favorite'] })) + : tab === 'rated' + ? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}★`] })) + : []; + + return ( +
+
+
+
+
+

{guest.name}

+ {guest.email && ( +

{guest.email}

+ )} +
+ +
+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {/* Stats */} +
+
+
+ {liked.length} +
+
+ + {t('admin.guests.columns.likes', 'Likes')} +
+
+
+
+ {favorited.length} +
+
+ + {t('admin.guests.columns.favorites', 'Favorites')} +
+
+
+
+ {rated.length} +
+
+ + {t('admin.guests.columns.ratings', 'Ratings')} +
+
+
+
+ {commented.length} +
+
+ + {t('admin.guests.columns.comments', 'Comments')} +
+
+
+ + {/* Tabs */} +
+ {(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => ( + + ))} +
+ + {/* Content */} + {tab === 'commented' ? ( + commented.length === 0 ? ( +
+ {t('admin.guests.detail.noComments', 'No comments')} +
+ ) : ( +
+ {commented.map((c, idx) => ( +
+ +
+
+ {c.photo.filename} · {new Date(c.created_at).toLocaleString()} +
+

{c.comment}

+
+
+ ))} +
+ ) + ) : visibleItems.length === 0 ? ( +
+ {t('admin.guests.detail.empty', 'No selections in this category')} +
+ ) : ( +
+ {visibleItems.map((item) => ( +
+ +
+ {item.badges.map((b, i) => ( + + {b === 'like' ? '♥' : b === 'favorite' ? '★' : b} + + ))} +
+
+ {item.photo.filename} +
+
+ ))} +
+ )} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/admin/AdminGuestsList.tsx b/frontend/src/components/admin/AdminGuestsList.tsx new file mode 100644 index 00000000..48ddebf2 --- /dev/null +++ b/frontend/src/components/admin/AdminGuestsList.tsx @@ -0,0 +1,344 @@ +import React, { useState } from 'react'; +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react'; +import { Card, Button, Loading } from '../common'; +import { guestsService, AdminGuest } from '../../services/guests.service'; +import { AdminGuestDetail } from './AdminGuestDetail'; +import { GuestSelectionsAggregate } from './GuestSelectionsAggregate'; +import { GuestInviteDialog } from './GuestInviteDialog'; +import { toast } from 'react-toastify'; + +interface AdminGuestsListProps { + eventId: number; + eventName?: string; +} + +type View = 'list' | 'aggregate'; + +export const AdminGuestsList: React.FC = ({ eventId, eventName }) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [view, setView] = useState('list'); + const [selectedGuest, setSelectedGuest] = useState(null); + const [mergeMode, setMergeMode] = useState(false); + const [mergeSelection, setMergeSelection] = useState([]); + const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + + const { data, isLoading, refetch } = useQuery({ + queryKey: ['admin-guests', eventId], + queryFn: () => guestsService.getEventGuests(eventId), + }); + + const deleteMutation = useMutation({ + mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId), + onSuccess: () => { + toast.success(t('admin.guests.deletedToast', 'Guest removed')); + queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] }); + }, + onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')), + }); + + const mergeMutation = useMutation({ + mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) => + guestsService.mergeGuests(eventId, keepId, mergeIds), + onSuccess: () => { + toast.success(t('admin.guests.mergedToast', 'Guests merged')); + setMergeMode(false); + setMergeSelection([]); + queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] }); + }, + onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')), + }); + + const handleDelete = (guest: AdminGuest) => { + if (window.confirm(t('admin.guests.forgetGuestConfirm', 'Remove this guest? Their picks will be anonymized but kept in aggregate totals.'))) { + deleteMutation.mutate(guest.id); + } + }; + + const handleExport = async (guest: AdminGuest, format: 'txt' | 'csv' | 'json') => { + try { + const blob = await guestsService.exportGuest(eventId, guest.id, format); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${guest.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.${format}`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch { + toast.error(t('admin.guests.exportError', 'Export failed')); + } + }; + + const handleExportAll = async (format: 'txt' | 'csv' | 'json') => { + try { + const blob = await guestsService.exportAllGuests(eventId, format); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `event-${eventId}-guests.zip`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + } catch { + toast.error(t('admin.guests.exportError', 'Export failed')); + } + }; + + const toggleMergeSelection = (id: number) => { + setMergeSelection((prev) => + prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] + ); + }; + + const performMerge = () => { + if (mergeSelection.length < 2) { + toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge')); + return; + } + const [keepId, ...mergeIds] = mergeSelection; + const keepName = data?.guests.find((g) => g.id === keepId)?.name; + const confirmMsg = t( + 'admin.guests.mergeConfirm', + 'Merge {{count}} guests into {{name}}? This cannot be undone.', + { count: mergeSelection.length, name: keepName || '#' + keepId } + ); + if (window.confirm(confirmMsg)) { + mergeMutation.mutate({ keepId, mergeIds }); + } + }; + + if (isLoading) { + return ; + } + + const guests = data?.guests || []; + + if (view === 'aggregate') { + return ( +
+
+
+ +
+
+ +
+ ); + } + + return ( +
+
+

+ {t('admin.guests.title', 'Guests')} ({guests.length}) +

+
+ {mergeMode ? ( + <> + + {t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })} + + + + + ) : ( + <> + + + +
+ +
+ {(['csv', 'txt', 'json'] as const).map((fmt) => ( + + ))} +
+
+ + )} +
+
+ + {guests.length === 0 ? ( + +
+ {t('admin.guests.empty', 'No guests have registered yet.')} +
+
+ ) : ( + +
+ + + + {mergeMode && + + + + + + + + + + {guests.map((guest) => ( + + {mergeMode && ( + + )} + + + + + + + + + + ))} + +
} + + {t('admin.guests.columns.name', 'Name')} + + {t('admin.guests.columns.email', 'Email')} + + {t('admin.guests.columns.likes', 'Likes')} + + {t('admin.guests.columns.favorites', 'Favorites')} + + {t('admin.guests.columns.comments', 'Comments')} + + {t('admin.guests.columns.ratings', 'Ratings')} + + {t('admin.guests.columns.lastSeen', 'Last seen')} + +
+ toggleMergeSelection(guest.id)} + className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500" + /> + + {guest.name} + {guest.email_verified_at && ( + + )} + + {guest.email || '—'} + + {guest.stats.likes} + + {guest.stats.favorites} + + {guest.stats.comments} + + {guest.stats.ratings} + + {new Date(guest.last_seen_at).toLocaleDateString()} + +
+ +
+ +
+ {(['csv', 'txt', 'json'] as const).map((fmt) => ( + + ))} +
+
+ +
+
+
+
+ )} + + {selectedGuest && ( + setSelectedGuest(null)} + /> + )} + + {inviteDialogOpen && ( + { + setInviteDialogOpen(false); + refetch(); + }} + /> + )} +
+ ); +}; diff --git a/frontend/src/components/admin/FeedbackSettings.tsx b/frontend/src/components/admin/FeedbackSettings.tsx index 3cdb79b8..23cb7505 100644 --- a/frontend/src/components/admin/FeedbackSettings.tsx +++ b/frontend/src/components/admin/FeedbackSettings.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react'; +import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react'; import { Card } from '../common'; import { useTranslation } from 'react-i18next'; @@ -21,6 +21,7 @@ interface FeedbackSettings { enable_rate_limiting: boolean; rate_limit_window_minutes?: number; rate_limit_max_requests?: number; + identity_mode?: 'simple' | 'guest'; } export const FeedbackSettings: React.FC = ({ @@ -70,6 +71,74 @@ export const FeedbackSettings: React.FC = ({ {settings.feedback_enabled && ( <> + {/* Identity Mode */} +
+

+ {t('feedback.settings.identityMode', 'Identity Mode')} +

+
+ + + +
+
+ +
+ {/* Feedback Types */}

diff --git a/frontend/src/components/admin/GuestInviteDialog.tsx b/frontend/src/components/admin/GuestInviteDialog.tsx new file mode 100644 index 00000000..718c3384 --- /dev/null +++ b/frontend/src/components/admin/GuestInviteDialog.tsx @@ -0,0 +1,194 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { X, Copy, Check, Trash2 } from 'lucide-react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button, Input, Loading } from '../common'; +import { guestsService, GuestInvite } from '../../services/guests.service'; +import { toast } from 'react-toastify'; + +interface GuestInviteDialogProps { + eventId: number; + eventName?: string; + onClose: () => void; +} + +/** + * Admin dialog to create pre-minted invite tokens and list existing ones. + * Each invite generates a unique URL that the admin can send to a specific + * guest. Opening the URL auto-registers that guest (single use). + */ +export const GuestInviteDialog: React.FC = ({ eventId, onClose }) => { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [copiedId, setCopiedId] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ['admin-guest-invites', eventId], + queryFn: () => guestsService.listInvites(eventId), + }); + + const createMutation = useMutation({ + mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }), + onSuccess: () => { + setName(''); + setEmail(''); + toast.success(t('admin.guests.inviteCreated', 'Invite created')); + queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] }); + queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] }); + }, + onError: () => toast.error(t('admin.guests.inviteCreateError', 'Failed to create invite')), + }); + + const revokeMutation = useMutation({ + mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId), + onSuccess: () => { + toast.success(t('admin.guests.inviteRevoked', 'Invite revoked')); + queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] }); + }, + onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')), + }); + + const copy = (invite: GuestInvite) => { + navigator.clipboard.writeText(invite.url).then(() => { + setCopiedId(invite.id); + setTimeout(() => setCopiedId(null), 1500); + }); + }; + + const invites = data?.invites || []; + + return ( +
+
+
+
+

+ {t('admin.guests.invitesTitle', 'Guest invites')} +

+ +
+ +
+ {/* Create form */} +
+

+ {t('admin.guests.createInvite', 'Create invite')} +

+
+ setName(e.target.value)} + placeholder="e.g. Alice" + required + /> + setEmail(e.target.value)} + placeholder="alice@example.com" + /> +
+ +
+ + {/* Existing invites */} +
+

+ {t('admin.guests.existingInvites', 'Existing invites')} +

+ {isLoading ? ( + + ) : invites.length === 0 ? ( +
+ {t('admin.guests.noInvites', 'No invites yet')} +
+ ) : ( +
+ {invites.map((invite) => ( +
+
+
+
+ {invite.guest.name} + {invite.guest.email && ( + + · {invite.guest.email} + + )} +
+
+ + {t(`admin.guests.inviteStatus.${invite.status}`, invite.status)} + +
+
+ {invite.url} +
+
+
+ {invite.status === 'pending' && ( + <> + + + + )} +
+
+
+ ))} +
+ )} +
+
+
+
+ ); +}; diff --git a/frontend/src/components/admin/GuestSelectionsAggregate.tsx b/frontend/src/components/admin/GuestSelectionsAggregate.tsx new file mode 100644 index 00000000..085613ee --- /dev/null +++ b/frontend/src/components/admin/GuestSelectionsAggregate.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { Users } from 'lucide-react'; +import { Card, Loading } from '../common'; +import { guestsService } from '../../services/guests.service'; +import { AuthenticatedImage } from '../common/AuthenticatedImage'; +import { buildResourceUrl } from '../../utils/url'; + +interface GuestSelectionsAggregateProps { + eventId: number; +} + +/** + * Shows photos sorted by the number of distinct guests who liked or + * favorited them. Photos with zero picks are filtered server-side. + */ +export const GuestSelectionsAggregate: React.FC = ({ eventId }) => { + const { t } = useTranslation(); + const { data, isLoading } = useQuery({ + queryKey: ['admin-guests-aggregate', eventId], + queryFn: () => guestsService.getAggregatePicks(eventId), + }); + + if (isLoading) { + return ; + } + + const photos = data?.photos || []; + + if (photos.length === 0) { + return ( + +
+ {t('admin.guests.aggregate.empty', 'No guest picks yet.')} +
+
+ ); + } + + return ( +
+

+ {t( + 'admin.guests.aggregate.description', + 'Photos sorted by how many distinct guests liked or favorited them.' + )} +

+
+ {photos.map((p) => ( +
+ +
+ + {p.picker_count} +
+
+ {p.original_filename || p.filename} +
+
+ ))} +
+
+ ); +}; diff --git a/frontend/src/components/admin/index.ts b/frontend/src/components/admin/index.ts index 3f9f3157..61a8c9dd 100644 --- a/frontend/src/components/admin/index.ts +++ b/frontend/src/components/admin/index.ts @@ -36,3 +36,7 @@ export { EventRenameDialog } from './EventRenameDialog'; export { PhotoFilterPanel } from './PhotoFilterPanel'; export { PhotoExportMenu } from './PhotoExportMenu'; export { CssTemplateEditor } from './CssTemplateEditor'; +export { AdminGuestsList } from './AdminGuestsList'; +export { AdminGuestDetail } from './AdminGuestDetail'; +export { GuestSelectionsAggregate } from './GuestSelectionsAggregate'; +export { GuestInviteDialog } from './GuestInviteDialog'; diff --git a/frontend/src/components/gallery/GalleryLayout.tsx b/frontend/src/components/gallery/GalleryLayout.tsx index f709ebf3..db3c769e 100644 --- a/frontend/src/components/gallery/GalleryLayout.tsx +++ b/frontend/src/components/gallery/GalleryLayout.tsx @@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button } from '../common'; import { DynamicFavicon } from '../common/DynamicFavicon'; import { useTheme } from '../../contexts/ThemeContext'; +import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; import { buildResourceUrl } from '../../utils/url'; import type { HeaderStyleType } from '../../types/theme.types'; @@ -59,6 +60,7 @@ export const GalleryLayout: React.FC = ({ const { t } = useTranslation(); const { format } = useLocalizedDate(); const { theme } = useTheme(); + const guestIdentity = useGuestIdentityOptional(); // Determine header style - use prop first (from event data), then theme, then fall back to 'standard' const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard'; @@ -589,20 +591,36 @@ export const GalleryLayout: React.FC = ({

)} {/* Legal Links */} -
- + {t('legal.impressum')} | - {t('legal.datenschutz')} + {guestIdentity?.identity && ( + <> + | + + + )}
diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 3bf20333..3eb8764c 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -13,6 +13,9 @@ import { GalleryLayout } from './GalleryLayout'; import { GallerySidebar } from './GallerySidebar'; import { PhotoFilterBar } from './PhotoFilterBar'; import { UserPhotoUpload } from './UserPhotoUpload'; +import { GuestNamePromptModal } from './GuestNamePromptModal'; +import { GuestRecoveryModal } from './GuestRecoveryModal'; +import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext'; import type { FilterType } from './GalleryFilter'; import { analyticsService } from '../../services/analytics.service'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; @@ -701,8 +704,14 @@ export const GalleryView: React.FC = ({ slug, event }) => { ); } + const identityMode: 'simple' | 'guest' = + feedbackSettings?.identity_mode === 'guest' ? 'guest' : 'simple'; + return ( + <> + + {/* Sidebar for non-grid layouts */} {showSidebar ? ( = ({ slug, event }) => { )} + ); }; diff --git a/frontend/src/components/gallery/GuestNamePromptModal.tsx b/frontend/src/components/gallery/GuestNamePromptModal.tsx new file mode 100644 index 00000000..95c3efd6 --- /dev/null +++ b/frontend/src/components/gallery/GuestNamePromptModal.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import { X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Input } from '../common'; +import { useGuestIdentity } from '../../contexts/GuestIdentityContext'; + +interface GuestNamePromptModalProps { + requireEmail?: boolean; + allowCancel?: boolean; + onCancel?: () => void; +} + +/** + * Session-wide prompt shown in guest identity mode when no identity exists + * yet. Triggered by `ensureIdentity()` on the first interactive feedback + * attempt, or manually via `openPrompt()`. + * + * Includes a link to the recovery flow for users who already registered on + * another device. + */ +export const GuestNamePromptModal: React.FC = ({ + requireEmail = false, + allowCancel = true, + onCancel, +}) => { + const { t } = useTranslation(); + const { promptOpen, closePrompt, register, openRecovery } = useGuestIdentity(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [errors, setErrors] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + if (!promptOpen) return null; + + const handleClose = () => { + setName(''); + setEmail(''); + setErrors({}); + setSubmitError(null); + closePrompt(); + onCancel?.(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const newErrors: Record = {}; + if (!name.trim()) { + newErrors.name = t('gallery.guestPrompt.nameRequired', 'Name is required'); + } + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + newErrors.email = t('gallery.guestPrompt.invalidEmail', 'Invalid email address'); + } + if (requireEmail && !email.trim()) { + newErrors.email = t('gallery.guestPrompt.emailRequired', 'Email is required'); + } + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors); + return; + } + + setSubmitting(true); + setSubmitError(null); + try { + await register(name.trim(), email.trim() || undefined); + } catch (err) { + const error = err as { response?: { data?: { error?: string } } }; + setSubmitError(error.response?.data?.error || t('gallery.guestPrompt.error', 'Registration failed')); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ {allowCancel && ( + + )} + +

+ {t('gallery.guestPrompt.title', "Welcome — what's your name?")} +

+

+ {t( + 'gallery.guestPrompt.description', + 'Your picks will be saved under this name so the photographer knows which photos you love.' + )} +

+ +
+ setName(e.target.value)} + error={errors.name} + placeholder={t('gallery.guestPrompt.namePlaceholder', 'Enter your name')} + autoFocus + required + maxLength={100} + /> + setEmail(e.target.value)} + error={errors.email} + placeholder={t('gallery.guestPrompt.emailPlaceholder', 'you@example.com')} + maxLength={255} + /> + + {submitError && ( +
+ {submitError} +
+ )} + +
+ + {allowCancel && ( + + )} +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/gallery/GuestRecoveryModal.tsx b/frontend/src/components/gallery/GuestRecoveryModal.tsx new file mode 100644 index 00000000..44300d2d --- /dev/null +++ b/frontend/src/components/gallery/GuestRecoveryModal.tsx @@ -0,0 +1,173 @@ +import React, { useState } from 'react'; +import { X, ArrowLeft } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button, Input } from '../common'; +import { useGuestIdentity } from '../../contexts/GuestIdentityContext'; + +/** + * Email-based identity recovery flow (Phase 3.2). + * + * Two steps: + * 1) Enter email → server sends a 6-digit code. + * 2) Enter code → server returns a guest token, identity restored. + * + * Opens when the user clicks "I've been here before" in the name prompt. + */ +export const GuestRecoveryModal: React.FC = () => { + const { t } = useTranslation(); + const { recoveryOpen, closeRecovery, recoverRequest, recoverVerify, openPrompt } = + useGuestIdentity(); + + const [step, setStep] = useState<'email' | 'code'>('email'); + const [email, setEmail] = useState(''); + const [code, setCode] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + + if (!recoveryOpen) return null; + + const reset = () => { + setStep('email'); + setEmail(''); + setCode(''); + setSubmitting(false); + setError(null); + setInfo(null); + }; + + const handleClose = () => { + reset(); + closeRecovery(); + }; + + const backToPrompt = () => { + reset(); + closeRecovery(); + openPrompt(); + }; + + const handleRequestCode = async (e: React.FormEvent) => { + e.preventDefault(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setError(t('gallery.guestRecovery.invalidEmail', 'Enter a valid email address')); + return; + } + setSubmitting(true); + setError(null); + try { + await recoverRequest(email.trim().toLowerCase()); + setInfo(t('gallery.guestRecovery.codeSent', 'Check your inbox for a verification code.')); + setStep('code'); + } catch { + setError(t('gallery.guestRecovery.requestError', 'Could not send code. Try again.')); + } finally { + setSubmitting(false); + } + }; + + const handleVerify = async (e: React.FormEvent) => { + e.preventDefault(); + if (!/^\d{6}$/.test(code.trim())) { + setError(t('gallery.guestRecovery.invalidCode', 'Enter the 6-digit code')); + return; + } + setSubmitting(true); + setError(null); + try { + await recoverVerify(email.trim().toLowerCase(), code.trim()); + // Success: context clears recoveryOpen on success, component will + // unmount naturally. + } catch { + setError(t('gallery.guestRecovery.verifyError', 'Invalid or expired code.')); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+ + + + +

+ {t('gallery.guestRecovery.title', 'Recover your picks')} +

+

+ {step === 'email' + ? t( + 'gallery.guestRecovery.emailStepDescription', + 'Enter the email you used before. We will send a 6-digit verification code.' + ) + : t( + 'gallery.guestRecovery.codeStepDescription', + 'Enter the 6-digit code we sent to your email.' + )} +

+ + {info && step === 'code' && ( +
+ {info} +
+ )} + {error && ( +
+ {error} +
+ )} + + {step === 'email' ? ( +
+ setEmail(e.target.value)} + placeholder="you@example.com" + autoFocus + required + /> + +
+ ) : ( +
+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="123456" + maxLength={6} + autoFocus + required + /> + +
+ )} +
+
+ ); +}; diff --git a/frontend/src/components/gallery/PhotoComments.tsx b/frontend/src/components/gallery/PhotoComments.tsx index 0ce80386..46a23359 100644 --- a/frontend/src/components/gallery/PhotoComments.tsx +++ b/frontend/src/components/gallery/PhotoComments.tsx @@ -7,6 +7,7 @@ import { toast } from 'react-toastify'; import { format } from 'date-fns'; import { Button, Input } from '../common'; import type { PhotoFeedback } from '../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; interface PhotoCommentsProps { photoId: string; @@ -29,6 +30,8 @@ export const PhotoComments: React.FC = ({ }) => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const guestIdentity = useGuestIdentityOptional(); + const isGuestMode = guestIdentity?.identityMode === 'guest'; const [showCommentForm, setShowCommentForm] = useState(false); const [commentText, setCommentText] = useState(''); const [guestName, setGuestName] = useState(''); @@ -78,7 +81,7 @@ export const PhotoComments: React.FC = ({ } }); - const handleSubmitComment = (e: React.FormEvent) => { + const handleSubmitComment = async (e: React.FormEvent) => { e.preventDefault(); setErrors({}); @@ -87,7 +90,9 @@ export const PhotoComments: React.FC = ({ if (!commentText.trim()) { newErrors.comment_text = t('feedback.commentRequired', 'Comment is required'); } - if (requireNameEmail) { + // In guest identity mode, name/email come from the guest token — don't + // ask for them here. + if (requireNameEmail && !isGuestMode) { if (!guestName.trim()) { newErrors.guest_name = t('feedback.nameRequired', 'Name is required'); } @@ -101,6 +106,16 @@ export const PhotoComments: React.FC = ({ return; } + if (isGuestMode && guestIdentity) { + try { + await guestIdentity.ensureIdentity(); + } catch { + return; + } + submitCommentMutation.mutate({ comment_text: commentText.trim() }); + return; + } + submitCommentMutation.mutate({ comment_text: commentText.trim(), guest_name: guestName.trim() || undefined, @@ -140,7 +155,7 @@ export const PhotoComments: React.FC = ({ {/* Comment Form */} {showCommentForm && (
- {requireNameEmail && ( + {requireNameEmail && !isGuestMode && (
= ({ }) => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const guestIdentity = useGuestIdentityOptional(); const [isSubmitting, setIsSubmitting] = useState(false); const [animating, setAnimating] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false); @@ -68,9 +70,19 @@ export const PhotoFavorites: React.FC = ({ } }); - const handleFavoriteClick = () => { + const handleFavoriteClick = async () => { if (!isEnabled || isSubmitting) return; - + + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + return; + } + submitFavoriteMutation.mutate({}); + return; + } + if (requireNameEmail && !savedIdentity) { setShowIdentityModal(true); } else { diff --git a/frontend/src/components/gallery/PhotoLikes.tsx b/frontend/src/components/gallery/PhotoLikes.tsx index 5fde4d79..d9561094 100644 --- a/frontend/src/components/gallery/PhotoLikes.tsx +++ b/frontend/src/components/gallery/PhotoLikes.tsx @@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { feedbackService } from '../../services/feedback.service'; import { toast } from 'react-toastify'; import { FeedbackIdentityModal } from './FeedbackIdentityModal'; +import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; interface PhotoLikesProps { photoId: string; @@ -27,6 +28,7 @@ export const PhotoLikes: React.FC = ({ }) => { const { t } = useTranslation(); const queryClient = useQueryClient(); + const guestIdentity = useGuestIdentityOptional(); const [isSubmitting, setIsSubmitting] = useState(false); const [animating, setAnimating] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false); @@ -68,9 +70,23 @@ export const PhotoLikes: React.FC = ({ } }); - const handleLikeClick = () => { + const handleLikeClick = async () => { if (!isEnabled || isSubmitting) return; - + + // Guest identity mode: ensure we have a per-person guest token. The + // server will read name/email from the token — body values are ignored. + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + // User cancelled the prompt — silently abort. + return; + } + submitLikeMutation.mutate({}); + return; + } + + // Simple mode (or no provider at all): legacy inline prompt flow. if (requireNameEmail && !savedIdentity) { setShowIdentityModal(true); } else { diff --git a/frontend/src/components/gallery/PhotoRating.tsx b/frontend/src/components/gallery/PhotoRating.tsx index 0b4caba7..3aad8d4d 100644 --- a/frontend/src/components/gallery/PhotoRating.tsx +++ b/frontend/src/components/gallery/PhotoRating.tsx @@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { feedbackService } from '../../services/feedback.service'; import { toast } from 'react-toastify'; import { FeedbackIdentityModal } from './FeedbackIdentityModal'; +import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext'; interface PhotoRatingProps { photoId: string; @@ -31,6 +32,7 @@ export const PhotoRating: React.FC = ({ const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0; const { t } = useTranslation(); const queryClient = useQueryClient(); + const guestIdentity = useGuestIdentityOptional(); const [hoveredRating, setHoveredRating] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); const [showIdentityModal, setShowIdentityModal] = useState(false); @@ -72,18 +74,28 @@ export const PhotoRating: React.FC = ({ } }); - const handleRatingClick = (rating: number) => { + const handleRatingClick = async (rating: number) => { if (!isEnabled || isSubmitting) return; - + // If clicking the same rating, remove it const newRating = rating === currentRating ? 0 : rating; - + + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + return; + } + submitRatingMutation.mutate({ rating: newRating }); + return; + } + if (requireNameEmail && !savedIdentity) { setPendingRating(newRating); setShowIdentityModal(true); } else { - submitRatingMutation.mutate({ - rating: newRating, + submitRatingMutation.mutate({ + rating: newRating, guest_name: savedIdentity?.name, guest_email: savedIdentity?.email }); diff --git a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx index 9fc6508c..6cea392c 100644 --- a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx @@ -5,6 +5,7 @@ import { AuthenticatedImage, Button } from '../../common'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; export const CarouselGalleryLayout: React.FC = ({ photos, @@ -66,6 +67,7 @@ export const CarouselGalleryLayout: React.FC = ({ const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); + const guestIdentity = useGuestIdentityOptional(); const [likedIds, setLikedIds] = useState>(new Set()); const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback); @@ -150,6 +152,20 @@ export const CarouselGalleryLayout: React.FC = ({ variant="ghost" size="sm" onClick={async () => { + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + return; + } + setLikedIds(prev => new Set(prev).add(currentPhoto.id)); + try { + await feedbackService.submitFeedback(slug!, String(currentPhoto.id), { + feedback_type: 'like', + }); + } catch (_) {} + return; + } if (feedbackOptions?.requireNameEmail && !savedIdentity) { setPendingAction({ type: 'like', photoId: currentPhoto.id }); setShowIdentityModal(true); diff --git a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx index 722ef5e8..1590e5e0 100644 --- a/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryPremiumLayout.tsx @@ -17,6 +17,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; import { AuthenticatedImage } from '../../common'; import { feedbackService } from '../../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import { FeedbackIdentityModal } from '../FeedbackIdentityModal'; import { galleryService } from '../../../services/gallery.service'; import { analyticsService } from '../../../services/analytics.service'; @@ -188,6 +189,7 @@ export const GalleryPremiumLayout: React.FC = ({ const [activeCategory, setActiveCategory] = useState(null); const [likedPhotoIds, setLikedPhotoIds] = useState>(new Set()); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); + const guestIdentity = useGuestIdentityOptional(); const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingLikePhotoId, setPendingLikePhotoId] = useState(null); @@ -237,6 +239,28 @@ export const GalleryPremiumLayout: React.FC = ({ const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => { e.stopPropagation(); + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + return; + } + setLikedPhotoIds(prev => { + const next = new Set(prev); + next.add(photo.id); + return next; + }); + try { + await feedbackService.submitFeedback(slug, String(photo.id), { + feedback_type: 'like', + }); + onFeedbackChange?.(); + } catch (err) { + console.warn('Like submit failed', err); + } + return; + } + if (feedbackOptions?.requireNameEmail && !savedIdentity) { setPendingLikePhotoId(photo.id); setShowIdentityModal(true); @@ -260,7 +284,7 @@ export const GalleryPremiumLayout: React.FC = ({ } catch (err) { console.warn('Like submit failed', err); } - }, [slug, savedIdentity, feedbackOptions, onFeedbackChange]); + }, [slug, savedIdentity, feedbackOptions, onFeedbackChange, guestIdentity]); const handleIdentitySubmit = useCallback(async (name: string, email: string) => { setSavedIdentity({ name, email }); diff --git a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx index 7118d61b..7cd73a42 100644 --- a/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GridGalleryLayout.tsx @@ -6,6 +6,7 @@ import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; @@ -61,6 +62,7 @@ const GridPhoto: React.FC = ({ onLikeSuccess }) => { const { t } = useTranslation(); + const guestIdentity = useGuestIdentityOptional(); const [overlayVisible, setOverlayVisible] = React.useState(false); const [isTouchDevice, setIsTouchDevice] = React.useState(false); const overlayTimeoutRef = React.useRef(null); @@ -259,6 +261,25 @@ const GridPhoto: React.FC = ({ className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`} onClick={async (e) => { e.stopPropagation(); + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + hideOverlay(); + return; + } + if (onLikeSuccess) onLikeSuccess(); + try { + await feedbackService.submitFeedback(slug!, String(photo.id), { + feedback_type: 'like', + }); + } catch (err) { + console.warn('Like submit failed, keeping optimistic UI', err); + } + if (onFeedbackChange) onFeedbackChange(); + hideOverlay(); + return; + } if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) { onRequireIdentity('like', photo.id); hideOverlay(); diff --git a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx index 2c1d3a8d..b5492888 100644 --- a/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/JustifiedGalleryLayout.tsx @@ -8,6 +8,7 @@ import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import { buildResourceUrl } from '../../../utils/url'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; @@ -81,6 +82,7 @@ const JustifiedPhoto: React.FC = ({ liked = false, onLikeSuccess, }) => { + const guestIdentity = useGuestIdentityOptional(); const [overlayVisible, setOverlayVisible] = useState(false); const [isTouchDevice, setIsTouchDevice] = useState(false); const overlayTimeoutRef = useRef(null); @@ -301,6 +303,25 @@ const JustifiedPhoto: React.FC = ({ }`} onClick={async (e) => { e.stopPropagation(); + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + hideOverlay(); + return; + } + if (onLikeSuccess) onLikeSuccess(); + try { + await feedbackService.submitFeedback(slug!, String(photo.id), { + feedback_type: 'like', + }); + } catch (err) { + console.warn('Like submit failed, keeping optimistic UI', err); + } + if (onFeedbackChange) onFeedbackChange(); + hideOverlay(); + return; + } if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) { onRequireIdentity('like', photo.id); hideOverlay(); diff --git a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx index ff05be8a..baaf59fa 100644 --- a/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MasonryGalleryLayout.tsx @@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import { calculateJustifiedLayout, createJustifiedPhotos, @@ -53,6 +54,7 @@ const MasonryPhoto: React.FC = ({ const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); + const guestIdentity = useGuestIdentityOptional(); // Calculate height based on actual photo aspect ratio // This preserves the photo's natural proportions in the masonry layout @@ -151,6 +153,17 @@ const MasonryPhoto: React.FC = ({ className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors" onClick={async (e) => { e.stopPropagation(); + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + return; + } + await feedbackService.submitFeedback(slug!, String(photo.id), { + feedback_type: 'like', + }); + return; + } if (feedbackOptions?.requireNameEmail && !savedIdentity) { setPendingAction({ type: 'like', photoId: photo.id }); setShowIdentityModal(true); diff --git a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx index 1506c3a6..89ab143f 100644 --- a/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/MosaicGalleryLayout.tsx @@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext'; import { AuthenticatedImage } from '../../common'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; @@ -53,6 +54,7 @@ const MosaicPhoto: React.FC = ({ const [showIdentityModal, setShowIdentityModal] = React.useState(false); const [pendingAction, setPendingAction] = React.useState(null); const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null); + const guestIdentity = useGuestIdentityOptional(); const [likedLocal, setLikedLocal] = React.useState(false); const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment); @@ -108,6 +110,20 @@ const MosaicPhoto: React.FC = ({ className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`} onClick={async (e) => { e.stopPropagation(); + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + return; + } + setLikedLocal(true); + try { + await feedbackService.submitFeedback(slug!, String(photo.id), { + feedback_type: 'like', + }); + } catch (_) {} + return; + } if (feedbackOptions?.requireNameEmail && !savedIdentity) { setPendingAction({ type: 'like', photoId: photo.id }); setShowIdentityModal(true); diff --git a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx index 3eb90f27..32480510 100644 --- a/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/TimelineGalleryLayout.tsx @@ -7,6 +7,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { Photo } from '../../../types'; import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal'; import { feedbackService } from '../../../services/feedback.service'; +import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext'; export const TimelineGalleryLayout: React.FC = ({ photos, @@ -26,6 +27,7 @@ export const TimelineGalleryLayout: React.FC = ({ const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); + const guestIdentity = useGuestIdentityOptional(); const gallerySettings = theme.gallerySettings || {}; const grouping = gallerySettings.timelineGrouping || 'day'; const showDates = gallerySettings.timelineShowDates !== false; @@ -147,6 +149,20 @@ export const TimelineGalleryLayout: React.FC = ({ className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`} onClick={async (e) => { e.stopPropagation(); + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + return; + } + setLikedIds(prev => new Set(prev).add(photo.id)); + try { + await feedbackService.submitFeedback(slug!, String(photo.id), { + feedback_type: 'like', + }); + } catch (_) {} + return; + } if (feedbackOptions?.requireNameEmail && !savedIdentity) { setPendingAction({ type: 'like', photoId: photo.id }); setShowIdentityModal(true); diff --git a/frontend/src/config/api.ts b/frontend/src/config/api.ts index 80e8494f..3a3a7aa6 100644 --- a/frontend/src/config/api.ts +++ b/frontend/src/config/api.ts @@ -5,6 +5,7 @@ import { inferGallerySlugFromLocation, resolveSlugFromRequestUrl, } from '../utils/galleryAuthStorage'; +import { getGuestToken } from '../utils/guestIdentityStorage'; import { getApiBaseUrl } from '../utils/url'; // Maintenance mode callback @@ -80,6 +81,25 @@ api.interceptors.request.use( } } } + + // Also inject guest token (x-guest-token) for per-person identity. + // Separate header so gallery auth and guest identity are independent. + const guestToken = getGuestToken(slug); + if (guestToken) { + if (!config.headers) { + config.headers = new AxiosHeaders(); + } + if (config.headers instanceof AxiosHeaders) { + if (!config.headers.get('x-guest-token')) { + config.headers.set('x-guest-token', guestToken); + } + } else { + const headersRecord = config.headers as Record; + if (!headersRecord['x-guest-token']) { + headersRecord['x-guest-token'] = guestToken; + } + } + } } } } diff --git a/frontend/src/contexts/GuestIdentityContext.tsx b/frontend/src/contexts/GuestIdentityContext.tsx new file mode 100644 index 00000000..132908d7 --- /dev/null +++ b/frontend/src/contexts/GuestIdentityContext.tsx @@ -0,0 +1,224 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { guestsService, GuestIdentity } from '../services/guests.service'; +import { + clearGuestIdentity, + getGuestIdentity, + storeGuestIdentity, +} from '../utils/guestIdentityStorage'; + +type IdentityMode = 'simple' | 'guest'; + +interface GuestIdentityContextValue { + slug: string; + identity: GuestIdentity | null; + identityMode: IdentityMode; + isRequired: boolean; // true when mode='guest' AND no identity yet + promptOpen: boolean; + recoveryOpen: boolean; + openPrompt: () => void; + closePrompt: () => void; + openRecovery: () => void; + closeRecovery: () => void; + register: (name: string, email?: string) => Promise; + recoverRequest: (email: string) => Promise; + recoverVerify: (email: string, code: string) => Promise; + forget: () => Promise; + /** + * Used by feedback components. Returns the current identity, or opens the + * prompt and waits until the user registers (or cancels, in which case it + * throws a "user_cancelled" error). + */ + ensureIdentity: () => Promise; +} + +const GuestIdentityContext = createContext(null); + +interface GuestIdentityProviderProps { + slug: string; + identityMode: IdentityMode; + children: React.ReactNode; +} + +export const GuestIdentityProvider: React.FC = ({ + slug, + identityMode, + children, +}) => { + const [identity, setIdentity] = useState(() => getGuestIdentity(slug)); + const [promptOpen, setPromptOpen] = useState(false); + const [recoveryOpen, setRecoveryOpen] = useState(false); + + // Pending promise resolvers for ensureIdentity() calls waiting on prompt. + const pendingResolvers = useRef void>>([]); + const pendingRejecters = useRef void>>([]); + + // Rehydrate identity when slug changes. + useEffect(() => { + setIdentity(getGuestIdentity(slug)); + }, [slug]); + + // When an invite token is present on the URL (?invite=xxx), redeem it once + // on mount. The server returns a guest token we can persist. + useEffect(() => { + if (identityMode !== 'guest' || identity) return; + const params = new URLSearchParams(window.location.search); + const inviteToken = params.get('invite'); + if (!inviteToken) return; + + (async () => { + try { + const response = await guestsService.redeemInvite(slug, inviteToken); + storeGuestIdentity(slug, response.guest, response.token); + setIdentity(response.guest); + // Strip invite param from URL to prevent re-redemption on reload. + params.delete('invite'); + const newSearch = params.toString(); + const newUrl = window.location.pathname + (newSearch ? `?${newSearch}` : '') + window.location.hash; + window.history.replaceState({}, '', newUrl); + } catch (error) { + // Silently fail invalid invites; user will fall back to normal prompt. + // eslint-disable-next-line no-console + console.warn('Failed to redeem invite token', error); + } + })(); + }, [slug, identityMode, identity]); + + const openPrompt = useCallback(() => setPromptOpen(true), []); + const closePrompt = useCallback(() => { + setPromptOpen(false); + // Reject any pending ensureIdentity() promises. + pendingRejecters.current.forEach((r) => r(new Error('user_cancelled'))); + pendingResolvers.current = []; + pendingRejecters.current = []; + }, []); + + const openRecovery = useCallback(() => setRecoveryOpen(true), []); + const closeRecovery = useCallback(() => setRecoveryOpen(false), []); + + const register = useCallback( + async (name: string, email?: string): Promise => { + const response = await guestsService.registerGuest(slug, { name, email }); + storeGuestIdentity(slug, response.guest, response.token); + setIdentity(response.guest); + setPromptOpen(false); + // Resolve pending ensureIdentity() promises. + pendingResolvers.current.forEach((r) => r(response.guest)); + pendingResolvers.current = []; + pendingRejecters.current = []; + return response.guest; + }, + [slug] + ); + + const recoverRequest = useCallback( + async (email: string): Promise => { + await guestsService.requestRecoveryCode(slug, email); + }, + [slug] + ); + + const recoverVerify = useCallback( + async (email: string, code: string): Promise => { + const response = await guestsService.verifyRecoveryCode(slug, email, code); + storeGuestIdentity(slug, response.guest, response.token); + setIdentity(response.guest); + setPromptOpen(false); + setRecoveryOpen(false); + pendingResolvers.current.forEach((r) => r(response.guest)); + pendingResolvers.current = []; + pendingRejecters.current = []; + return response.guest; + }, + [slug] + ); + + const forget = useCallback(async (): Promise => { + try { + if (identity) { + await guestsService.forgetMe(slug); + } + } catch { + // Best-effort. Clear local state regardless. + } + clearGuestIdentity(slug); + setIdentity(null); + }, [slug, identity]); + + const ensureIdentity = useCallback((): Promise => { + if (identityMode !== 'guest') { + // In simple mode, there is no per-person identity. Return a synthetic + // "null" identity that callers will ignore. + return Promise.resolve({ + id: 0, + name: '', + email: null, + identifier: '', + } as GuestIdentity); + } + if (identity) return Promise.resolve(identity); + + return new Promise((resolve, reject) => { + pendingResolvers.current.push(resolve); + pendingRejecters.current.push(reject); + setPromptOpen(true); + }); + }, [identityMode, identity]); + + const isRequired = identityMode === 'guest' && !identity; + + const value = useMemo( + () => ({ + slug, + identity, + identityMode, + isRequired, + promptOpen, + recoveryOpen, + openPrompt, + closePrompt, + openRecovery, + closeRecovery, + register, + recoverRequest, + recoverVerify, + forget, + ensureIdentity, + }), + [ + slug, + identity, + identityMode, + isRequired, + promptOpen, + recoveryOpen, + openPrompt, + closePrompt, + openRecovery, + closeRecovery, + register, + recoverRequest, + recoverVerify, + forget, + ensureIdentity, + ] + ); + + return {children}; +}; + +export function useGuestIdentity(): GuestIdentityContextValue { + const ctx = useContext(GuestIdentityContext); + if (!ctx) { + throw new Error('useGuestIdentity must be used within a GuestIdentityProvider'); + } + return ctx; +} + +/** + * Safe hook that returns null if no provider is present. Useful when code + * needs to optionally tie into guest identity without crashing when used + * outside a gallery (e.g. in admin contexts). + */ +export function useGuestIdentityOptional(): GuestIdentityContextValue | null { + return useContext(GuestIdentityContext); +} diff --git a/frontend/src/hooks/useGalleryFeedbackAction.ts b/frontend/src/hooks/useGalleryFeedbackAction.ts new file mode 100644 index 00000000..ae839110 --- /dev/null +++ b/frontend/src/hooks/useGalleryFeedbackAction.ts @@ -0,0 +1,69 @@ +import { useCallback } from 'react'; +import { feedbackService } from '../services/feedback.service'; +import { useGuestIdentityOptional } from '../contexts/GuestIdentityContext'; + +/** + * Shared helper used by gallery layout "quick action" buttons (like, favorite, + * rating, etc.) to submit feedback with proper identity handling: + * + * - In guest identity mode: ensures the visitor has a guest token (prompts + * if needed), then submits. Server reads name/email from the token. + * - In simple mode with require_name_email: callers still need to show + * their own inline FeedbackIdentityModal (we return `needsSimpleIdentity` + * to signal this). + * - In simple mode without require_name_email: submits directly. + */ +export function useGalleryFeedbackAction() { + const guestIdentity = useGuestIdentityOptional(); + + /** + * Submit a feedback action. Returns: + * - { submitted: true } if the submission happened. + * - { cancelled: true } if the user cancelled the guest prompt. + * - { needsSimpleIdentity: true } if the caller must show its own legacy + * identity modal (simple mode with require_name_email). + */ + const submit = useCallback( + async ( + slug: string, + photoId: number | string, + action: { + feedback_type: 'like' | 'favorite' | 'rating' | 'comment'; + rating?: number; + comment_text?: string; + }, + options?: { + requireNameEmail?: boolean; + savedIdentity?: { name: string; email: string } | null; + } + ): Promise<{ submitted?: boolean; cancelled?: boolean; needsSimpleIdentity?: boolean }> => { + if (guestIdentity?.identityMode === 'guest') { + try { + await guestIdentity.ensureIdentity(); + } catch { + return { cancelled: true }; + } + await feedbackService.submitFeedback(slug, String(photoId), action); + return { submitted: true }; + } + + // Simple mode + if (options?.requireNameEmail && !options.savedIdentity) { + return { needsSimpleIdentity: true }; + } + + await feedbackService.submitFeedback(slug, String(photoId), { + ...action, + guest_name: options?.savedIdentity?.name, + guest_email: options?.savedIdentity?.email, + }); + return { submitted: true }; + }, + [guestIdentity] + ); + + return { + submit, + isGuestMode: guestIdentity?.identityMode === 'guest', + }; +} diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 32749ccb..767b7a1f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2320,7 +2320,12 @@ "comments": "Comments", "commentsDesc": "Text comments on photos", "favorites": "Favorites", - "favoritesDesc": "Mark photos as favorites" + "favoritesDesc": "Mark photos as favorites", + "identityMode": "Identity Mode", + "identityModeSimple": "Simple feedback", + "identityModeSimpleDesc": "Anonymous, device-based. All visitors on the same device share state.", + "identityModeGuest": "Per-guest selections", + "identityModeGuestDesc": "Each visitor enters their name. Enables per-guest tracking and admin insights." } }, "filter": { diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 70f2ae46..5fb14cfa 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -53,7 +53,7 @@ import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Input, Card, Loading } from '../../components/common'; -import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin'; +import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { publicSettingsService } from '../../services/publicSettings.service'; @@ -232,7 +232,7 @@ export const EventDetailsPage: React.FC = () => { const [clientPin, setClientPin] = useState(''); const [showPhotoUpload, setShowPhotoUpload] = useState(false); const [showExternalImport, setShowExternalImport] = useState(false); - const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories' | 'guests'>('overview'); const [externalPath, setExternalPath] = useState(''); const [importing, setImporting] = useState(false); const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null); @@ -912,6 +912,18 @@ export const EventDetailsPage: React.FC = () => { > {t('events.categories')} + {eventFeedbackSettings?.identity_mode === 'guest' && ( + + )}
@@ -2104,6 +2116,11 @@ export const EventDetailsPage: React.FC = () => {
)} + {/* Guests Tab (only visible when identity_mode === 'guest') */} + {activeTab === 'guests' && eventFeedbackSettings?.identity_mode === 'guest' && ( + + )} + {/* Password Reset Modal */} {showPasswordReset && ( ; + commented: Array<{ photo: AdminGuestPhoto; comment: string; created_at: string }>; +} + +export interface AdminGuestDetail { + guest: AdminGuest; + selections: AdminGuestSelections; +} + +export interface AggregatePhoto extends AdminGuestPhoto { + picker_count: number; +} + +export interface GuestInvite { + id: number; + token: string; + url: string; + created_at: string; + redeemed_at: string | null; + revoked_at: string | null; + status: 'pending' | 'redeemed' | 'revoked'; + guest: { id: number; name: string; email: string | null }; +} + +class GuestsService { + // =================================================================== + // Gallery-side (public) — guest identity + // =================================================================== + + async registerGuest(slug: string, data: { name: string; email?: string }): Promise { + const response = await api.post(`/gallery/${slug}/guest`, data); + return response.data; + } + + async getGuestMe(slug: string): Promise<{ guest: GuestIdentity }> { + const response = await api.get(`/gallery/${slug}/guest/me`); + return response.data; + } + + async forgetMe(slug: string): Promise<{ success: boolean }> { + const response = await api.delete(`/gallery/${slug}/guest/me`); + return response.data; + } + + async requestRecoveryCode(slug: string, email: string): Promise<{ success: boolean }> { + const response = await api.post(`/gallery/${slug}/guest/recover`, { email }); + return response.data; + } + + async verifyRecoveryCode(slug: string, email: string, code: string): Promise { + const response = await api.post(`/gallery/${slug}/guest/verify`, { email, code }); + return response.data; + } + + async redeemInvite(slug: string, inviteToken: string): Promise { + const response = await api.post(`/gallery/${slug}/guest/redeem`, { inviteToken }); + return response.data; + } + + // =================================================================== + // Admin-side + // =================================================================== + + async getEventGuests(eventId: number): Promise<{ guests: AdminGuest[] }> { + const response = await api.get(`/admin/events/${eventId}/guests`); + return response.data; + } + + async getGuestDetail(eventId: number, guestId: number): Promise { + const response = await api.get(`/admin/events/${eventId}/guests/${guestId}`); + return response.data; + } + + async getAggregatePicks(eventId: number): Promise<{ photos: AggregatePhoto[] }> { + const response = await api.get(`/admin/events/${eventId}/guests/aggregate`); + return response.data; + } + + async deleteGuest(eventId: number, guestId: number): Promise { + await api.delete(`/admin/events/${eventId}/guests/${guestId}`); + } + + async mergeGuests(eventId: number, keepId: number, mergeIds: number[]): Promise { + await api.post(`/admin/events/${eventId}/guests/${keepId}/merge`, { mergeIds }); + } + + async exportGuest(eventId: number, guestId: number, format: 'txt' | 'csv' | 'json'): Promise { + const response = await api.get(`/admin/events/${eventId}/guests/${guestId}/export`, { + params: { format }, + responseType: 'blob', + }); + return response.data; + } + + async exportAllGuests(eventId: number, format: 'txt' | 'csv' | 'json'): Promise { + const response = await api.get(`/admin/events/${eventId}/guests/export-all`, { + params: { format }, + responseType: 'blob', + }); + return response.data; + } + + async listInvites(eventId: number): Promise<{ invites: GuestInvite[] }> { + const response = await api.get(`/admin/events/${eventId}/guests/invites`); + return response.data; + } + + async createInvite(eventId: number, data: { name: string; email?: string }): Promise<{ invite: GuestInvite }> { + const response = await api.post(`/admin/events/${eventId}/guests/invites`, data); + return response.data; + } + + async revokeInvite(eventId: number, inviteId: number): Promise { + await api.delete(`/admin/events/${eventId}/guests/invites/${inviteId}`); + } +} + +export const guestsService = new GuestsService(); diff --git a/frontend/src/utils/guestIdentityStorage.ts b/frontend/src/utils/guestIdentityStorage.ts new file mode 100644 index 00000000..89c26379 --- /dev/null +++ b/frontend/src/utils/guestIdentityStorage.ts @@ -0,0 +1,85 @@ +/** + * Per-gallery guest identity persistence. + * + * Stores the guest JWT and profile in sessionStorage, keyed by gallery slug, + * so multiple open tabs of the same gallery share identity but different + * browser contexts (and different galleries in the same context) stay + * independent. + */ + +import type { GuestIdentity } from '../services/guests.service'; + +const TOKEN_KEY_PREFIX = 'guest_token_'; +const IDENTITY_KEY_PREFIX = 'guest_identity_'; + +const isBrowser = typeof window !== 'undefined'; + +const getStorage = (): Storage | null => { + if (!isBrowser) return null; + try { + return window.sessionStorage; + } catch { + return null; + } +}; + +export function storeGuestIdentity(slug: string, identity: GuestIdentity, token: string): void { + const storage = getStorage(); + if (!storage || !slug) return; + storage.setItem(`${TOKEN_KEY_PREFIX}${slug}`, token); + storage.setItem(`${IDENTITY_KEY_PREFIX}${slug}`, JSON.stringify(identity)); +} + +export function getGuestToken(slug?: string | null): string | null { + const storage = getStorage(); + if (!storage) return null; + const resolvedSlug = slug || extractSlugFromLocation(); + if (!resolvedSlug) return null; + return storage.getItem(`${TOKEN_KEY_PREFIX}${resolvedSlug}`); +} + +export function getGuestIdentity(slug?: string | null): GuestIdentity | null { + const storage = getStorage(); + if (!storage) return null; + const resolvedSlug = slug || extractSlugFromLocation(); + if (!resolvedSlug) return null; + const raw = storage.getItem(`${IDENTITY_KEY_PREFIX}${resolvedSlug}`); + if (!raw) return null; + try { + return JSON.parse(raw) as GuestIdentity; + } catch { + return null; + } +} + +export function clearGuestIdentity(slug: string): void { + const storage = getStorage(); + if (!storage || !slug) return; + storage.removeItem(`${TOKEN_KEY_PREFIX}${slug}`); + storage.removeItem(`${IDENTITY_KEY_PREFIX}${slug}`); +} + +/** + * Extract the gallery slug from a request URL path like "/gallery/:slug/...". + * Matches the axios interceptor logic in api.ts. + */ +export function extractGuestSlugFromUrl(url: string): string | null { + if (!url) return null; + const pathOnly = url.startsWith('http://') || url.startsWith('https://') + ? (() => { + try { + return new URL(url).pathname; + } catch { + return url; + } + })() + : url; + const match = pathOnly.match(/\/gallery\/([^/?#]+)/); + return match ? decodeURIComponent(match[1]) : null; +} + +function extractSlugFromLocation(): string | null { + if (!isBrowser) return null; + const match = window.location.pathname.match(/\/gallery\/([^/?#]+)/); + return match ? decodeURIComponent(match[1]) : null; +}