diff --git a/backend/migrations/core/090_add_customer_accounts.js b/backend/migrations/core/090_add_customer_accounts.js
new file mode 100644
index 00000000..7fa5e3e8
--- /dev/null
+++ b/backend/migrations/core/090_add_customer_accounts.js
@@ -0,0 +1,313 @@
+/**
+ * Migration: Add Customer Accounts (recurring user logins)
+ *
+ * Implements the customer tier from discussion the-luap/picpeak#354.
+ *
+ * Three new tables:
+ * - customer_accounts : the user record (email + bcrypt password)
+ * - customer_invitations : admin → customer invite handshake (mirrors admin_invitations)
+ * - event_customer_assignments: many-to-many junction with events
+ *
+ * Three new RBAC permissions seeded so super_admin and admin roles can
+ * manage customers immediately after migrate. Editor / viewer remain
+ * locked out by design (matches the existing users.* permissions pattern).
+ *
+ * Migration is idempotent — every step checks for existing state so a
+ * partial install can be resumed.
+ */
+
+exports.up = async function(knex) {
+ // ---- customer_accounts -----------------------------------------------
+ if (!(await knex.schema.hasTable('customer_accounts'))) {
+ await knex.schema.createTable('customer_accounts', (table) => {
+ table.increments('id').primary();
+ table.string('email', 255).unique().notNullable();
+
+ // --- auth ---------------------------------------------------------
+ // password_hash is nullable until the invitation is accepted —
+ // an unaccepted account row exists only after acceptInvitation, so
+ // in practice this is always set, but the column is nullable to
+ // allow for future "admin creates pre-loaded account" flows.
+ table.string('password_hash', 255);
+ table.boolean('must_change_password').notNullable().defaultTo(false);
+ table.boolean('is_active').notNullable().defaultTo(true);
+ // Tracks password-change time so JWTs issued before a password
+ // change are rejected by customerAuth middleware. Mirrors the
+ // admin_users.password_changed_at column.
+ table.timestamp('password_changed_at');
+ table.timestamp('last_login');
+ table.string('last_login_ip', 45);
+ table.string('preferred_language', 8).defaultTo('en');
+
+ // --- contact ------------------------------------------------------
+ // Salutation honorific (Herr / Frau / Mx / Dr / Other). Stored as
+ // free text rather than an enum so future locales (German "Frau",
+ // French "Mme", legal titles "Dr.", etc.) don't need a migration.
+ table.string('salutation', 32);
+ table.string('first_name', 80);
+ table.string('last_name', 80);
+ // Convenience display name kept separately so the dashboard can
+ // greet customers without joining first/last (e.g. "Welcome, Luca").
+ table.string('display_name', 120);
+ table.string('phone', 40);
+ table.string('company_name', 120);
+
+ // --- billing / address (for future quotes & invoicing) -----------
+ table.string('billing_email', 255);
+ table.string('vat_id', 40);
+ table.string('address_line1', 255);
+ table.string('address_line2', 255);
+ table.string('postal_code', 20);
+ table.string('city', 120);
+ table.string('state', 120);
+ table.string('country_code', 2); // ISO 3166-1 alpha-2
+
+ // --- audit --------------------------------------------------------
+ table.text('notes'); // free-text admin notes, never shown to the customer
+ table.integer('created_by_admin_id').unsigned()
+ .references('id').inTable('admin_users').onDelete('SET NULL');
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.timestamp('updated_at').defaultTo(knex.fn.now());
+
+ table.index(['email']);
+ table.index(['is_active']);
+ table.index(['last_name']);
+ table.index(['company_name']);
+ });
+ }
+
+ // ---- customer_invitations --------------------------------------------
+ if (!(await knex.schema.hasTable('customer_invitations'))) {
+ await knex.schema.createTable('customer_invitations', (table) => {
+ table.increments('id').primary();
+ table.string('email', 255).notNullable();
+ // 64 chars = 32 bytes hex = 256 bits — same entropy as admin invites.
+ table.string('token', 64).unique().notNullable();
+ table.integer('invited_by').unsigned()
+ .references('id').inTable('admin_users').onDelete('CASCADE').notNullable();
+ table.timestamp('expires_at').notNullable();
+ table.timestamp('accepted_at');
+ table.integer('accepted_customer_id').unsigned()
+ .references('id').inTable('customer_accounts').onDelete('SET NULL');
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+
+ table.index(['token']);
+ table.index(['email']);
+ table.index(['expires_at']);
+ table.index(['accepted_at']);
+ });
+ }
+
+ // ---- event_customer_assignments --------------------------------------
+ if (!(await knex.schema.hasTable('event_customer_assignments'))) {
+ await knex.schema.createTable('event_customer_assignments', (table) => {
+ table.increments('id').primary();
+ table.integer('event_id').unsigned().notNullable()
+ .references('id').inTable('events').onDelete('CASCADE');
+ table.integer('customer_account_id').unsigned().notNullable()
+ .references('id').inTable('customer_accounts').onDelete('CASCADE');
+ table.integer('assigned_by_admin_id').unsigned()
+ .references('id').inTable('admin_users').onDelete('SET NULL');
+ table.timestamp('assigned_at').defaultTo(knex.fn.now());
+
+ table.unique(['event_id', 'customer_account_id']);
+ table.index(['customer_account_id']);
+ table.index(['event_id']);
+ });
+ }
+
+ // ---- RBAC permissions -------------------------------------------------
+ // Insert the three customers.* permissions if they're not already present
+ // (guards against re-running the migration in dev). The same idempotency
+ // pattern as 055_add_permissions_table.js.
+ const existingPermissions = await knex('permissions').select('name');
+ const existingNames = new Set(existingPermissions.map((p) => p.name));
+ const newPermissions = [
+ {
+ name: 'customers.view',
+ display_name: 'View Customers',
+ category: 'customers',
+ description: 'View customer accounts and their event assignments',
+ },
+ {
+ name: 'customers.create',
+ display_name: 'Invite Customers',
+ category: 'customers',
+ description: 'Issue customer invitations and assign customers to events',
+ },
+ {
+ name: 'customers.delete',
+ display_name: 'Deactivate Customers',
+ category: 'customers',
+ description: 'Deactivate customer accounts and revoke their access',
+ },
+ ].filter((p) => !existingNames.has(p.name));
+
+ if (newPermissions.length > 0) {
+ await knex('permissions').insert(newPermissions);
+ }
+
+ // Grant the three permissions to super_admin and admin so the feature
+ // is usable immediately. We look up role / permission ids fresh because
+ // the inserts above just landed.
+ const roles = await knex('roles').select('id', 'name')
+ .whereIn('name', ['super_admin', 'admin']);
+ const perms = await knex('permissions').select('id', 'name')
+ .whereIn('name', ['customers.view', 'customers.create', 'customers.delete']);
+
+ if (roles.length > 0 && perms.length > 0) {
+ const existing = await knex('role_permissions').select('role_id', 'permission_id');
+ const existingSet = new Set(existing.map((m) => `${m.role_id}-${m.permission_id}`));
+ const inserts = [];
+ for (const role of roles) {
+ for (const perm of perms) {
+ const key = `${role.id}-${perm.id}`;
+ if (!existingSet.has(key)) {
+ inserts.push({ role_id: role.id, permission_id: perm.id });
+ }
+ }
+ }
+ if (inserts.length > 0) {
+ await knex('role_permissions').insert(inserts);
+ }
+ }
+
+ // ---- email template seed ---------------------------------------------
+ // Schema is the post-075 shape: a master `email_templates` row keyed by
+ // template_key, plus one `email_template_translations` row per language.
+ // The legacy subject/body_html/body_text columns on email_templates may
+ // still exist for back-compat, so we populate both wherever the column
+ // is present — defensive, since some installs may run mid-upgrade.
+ if (await knex.schema.hasTable('email_templates')) {
+ const existing = await knex('email_templates')
+ .where('template_key', 'customer_invitation')
+ .first();
+
+ let templateId = existing?.id;
+ if (!existing) {
+ // Build the master row by introspecting the columns that actually
+ // exist on this install. The schema has drifted across migrations
+ // (075 adds language-specific columns then 075 normalises into a
+ // separate translations table; some installs lack `created_at` /
+ // `updated_at` on the master row). Anything not present is skipped
+ // silently rather than causing the whole migration to abort and
+ // taking the backend down with it.
+ const masterColumns = await knex('email_templates').columnInfo();
+ const insertRow = {
+ template_key: 'customer_invitation',
+ };
+ if (masterColumns.variables) {
+ insertRow.variables = JSON.stringify(['invite_link', 'expires_at']);
+ }
+ if (masterColumns.created_at) insertRow.created_at = knex.fn.now();
+ if (masterColumns.updated_at) insertRow.updated_at = knex.fn.now();
+ // Populate legacy single-language columns when present so older
+ // email service code paths still find a sensible default body.
+ if (masterColumns.subject) insertRow.subject = 'You\'ve been invited to access your photo galleries';
+ if (masterColumns.body_html) {
+ insertRow.body_html = '
You\'ve been invited to create a customer account. Set up your account (expires {{expires_at}}).
';
+ }
+ if (masterColumns.body_text) {
+ insertRow.body_text = 'Set up your customer account: {{invite_link}} (expires {{expires_at}}).';
+ }
+ // Some installs have language-specific master columns from migration 075.
+ if (masterColumns.subject_en) insertRow.subject_en = insertRow.subject || 'You\'ve been invited to access your photo galleries';
+ if (masterColumns.body_html_en) insertRow.body_html_en = insertRow.body_html || '';
+ if (masterColumns.body_text_en) insertRow.body_text_en = insertRow.body_text || '';
+
+ const [insertedId] = await knex('email_templates').insert(insertRow).returning('id');
+ templateId = insertedId?.id || insertedId;
+ }
+
+ if (templateId && await knex.schema.hasTable('email_template_translations')) {
+ const transColumns = await knex('email_template_translations').columnInfo();
+ const buildTranslationRow = (language, subject, bodyHtml, bodyText) => {
+ const row = { template_id: templateId, language };
+ if (transColumns.subject) row.subject = subject;
+ if (transColumns.body_html) row.body_html = bodyHtml;
+ if (transColumns.body_text) row.body_text = bodyText;
+ if (transColumns.created_at) row.created_at = new Date();
+ if (transColumns.updated_at) row.updated_at = new Date();
+ return row;
+ };
+
+ // The button uses the wrapper's `.button` class instead of inline
+ // styles, which inherits the admin-configured `email_primary_color`
+ // (Settings → Branding → Email palette). Inline `background-color`
+ // would override it and lock the button to the legacy green
+ // regardless of branding — that's the bug shipped on the very
+ // first cut of this template.
+ const en = buildTranslationRow(
+ 'en',
+ 'You\'ve been invited to access your photo galleries',
+ `
+Welcome to your photo galleries
+You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
+
+This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:
+{{invite_link}}
+If you weren't expecting this email, you can safely ignore it.
`,
+ `Welcome to your photo galleries
+
+You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
+
+Set up your account: {{invite_link}}
+
+This invitation expires on {{expires_at}}.
+
+If you weren't expecting this email, you can safely ignore it.`
+ );
+
+ const de = buildTranslationRow(
+ 'de',
+ 'Sie wurden eingeladen, auf Ihre Fotogalerien zuzugreifen',
+ `
+Willkommen bei Ihren Fotogalerien
+Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
+
+Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:
+{{invite_link}}
+Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.
`,
+ `Willkommen bei Ihren Fotogalerien
+
+Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
+
+Konto einrichten: {{invite_link}}
+
+Diese Einladung läuft am {{expires_at}} ab.
+
+Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.`
+ );
+
+ for (const row of [en, de]) {
+ const exists = await knex('email_template_translations')
+ .where({ template_id: templateId, language: row.language })
+ .first();
+ if (!exists) {
+ await knex('email_template_translations').insert(row);
+ }
+ }
+ }
+ }
+};
+
+exports.down = async function(knex) {
+ // Drop in reverse dependency order. Permissions / role grants are left
+ // alone — they're idempotent on re-run and cleaning them on rollback
+ // would require deleting role_permissions rows we may not own.
+ await knex.schema.dropTableIfExists('event_customer_assignments');
+ await knex.schema.dropTableIfExists('customer_invitations');
+ await knex.schema.dropTableIfExists('customer_accounts');
+
+ // Best-effort cleanup of the three customers.* permissions if no other
+ // code path inserted them. role_permissions rows cascade via FK.
+ await knex('permissions').whereIn('name', [
+ 'customers.view',
+ 'customers.create',
+ 'customers.delete',
+ ]).del();
+};
diff --git a/backend/migrations/core/091_add_customer_invitation_prefill.js b/backend/migrations/core/091_add_customer_invitation_prefill.js
new file mode 100644
index 00000000..d5b62d61
--- /dev/null
+++ b/backend/migrations/core/091_add_customer_invitation_prefill.js
@@ -0,0 +1,49 @@
+/**
+ * Migration: Allow admins to pre-fill customer profile fields on invite (#354 follow-up).
+ *
+ * Adds a single `prefill_data` JSON column to `customer_invitations`. When an
+ * admin invites a customer they can optionally pass first/last name, company,
+ * phone and a billing address — that data is stashed here and copied onto the
+ * new customer_accounts row by acceptInvitation(). The customer can then
+ * confirm or edit those values on the accept-invite form before submitting.
+ *
+ * JSON instead of one column per field because:
+ * - the prefill set may grow (vat id, salutation, etc.) and we don't want a
+ * migration for every UI tweak;
+ * - the data is only ever read+copied wholesale at accept time, never
+ * filtered/queried.
+ *
+ * Migration is idempotent — bails out if the column already exists.
+ */
+
+exports.up = async function(knex) {
+ const hasTable = await knex.schema.hasTable('customer_invitations');
+ if (!hasTable) {
+ // Migration 087 not yet run — nothing to alter. Should never happen in
+ // practice (knex runs migrations in order), but be defensive.
+ return;
+ }
+
+ const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
+ if (hasColumn) {
+ return;
+ }
+
+ await knex.schema.alterTable('customer_invitations', (table) => {
+ // Knex's `json` type maps to JSONB on Postgres and TEXT on SQLite, which
+ // matches how we already store other free-form payloads in this codebase
+ // (see app_settings.theme_config). Nullable: invitations sent the
+ // old way (or via the API without a body) should still work.
+ table.json('prefill_data');
+ });
+};
+
+exports.down = async function(knex) {
+ const hasTable = await knex.schema.hasTable('customer_invitations');
+ if (!hasTable) return;
+ const hasColumn = await knex.schema.hasColumn('customer_invitations', 'prefill_data');
+ if (!hasColumn) return;
+ await knex.schema.alterTable('customer_invitations', (table) => {
+ table.dropColumn('prefill_data');
+ });
+};
diff --git a/backend/migrations/core/092_customer_features_branding_resets.js b/backend/migrations/core/092_customer_features_branding_resets.js
new file mode 100644
index 00000000..2f4133f6
--- /dev/null
+++ b/backend/migrations/core/092_customer_features_branding_resets.js
@@ -0,0 +1,190 @@
+/**
+ * Migration: Customer-surface feature flags, branding toggles, and password resets (#354 follow-up).
+ *
+ * Three things in one migration so a single rollback returns the install
+ * to the prior state:
+ *
+ * 1. Per-customer feature flags on customer_accounts:
+ * feature_calendar / feature_quotes / feature_bills (BOOLEAN, default
+ * false). Default false because the matching pages are still
+ * coming-soon stubs — admin opts a customer in once the feature is
+ * actually useful for them. Combined with the global toggles below
+ * via AND-logic in the customer session response.
+ *
+ * 2. Global customer-surface toggles seeded into app_settings under
+ * setting_type='customer_surface':
+ * customer_feature_calendar_enabled (default false)
+ * customer_feature_quotes_enabled (default false)
+ * customer_feature_bills_enabled (default false)
+ * customer_show_logo (default true — preserves
+ * current visual behaviour)
+ * customer_show_company_name (default true — preserves
+ * current visual behaviour)
+ *
+ * 3. customer_password_resets table — admin-triggered password reset
+ * flow. Distinct from customer_invitations (which is the "create
+ * account" flow): a reset always points at an existing customer_id
+ * and updates the existing password_hash on accept.
+ *
+ * Plus the customer_password_reset email template, idempotently seeded.
+ *
+ * All steps are idempotent — a partial rollout can be resumed by re-running
+ * `knex migrate:latest`.
+ */
+
+exports.up = async function(knex) {
+ // ---- per-customer feature flags ---------------------------------------
+
+ if (await knex.schema.hasTable('customer_accounts')) {
+ const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
+ for (const col of cols) {
+ const exists = await knex.schema.hasColumn('customer_accounts', col);
+ if (!exists) {
+ // Add as a separate alterTable per column so a half-applied
+ // migration (column A added, B failing) leaves the table in a
+ // consistent state on retry.
+ await knex.schema.alterTable('customer_accounts', (table) => {
+ table.boolean(col).notNullable().defaultTo(false);
+ });
+ }
+ }
+ }
+
+ // ---- global customer-surface settings ---------------------------------
+
+ if (await knex.schema.hasTable('app_settings')) {
+ const seeds = [
+ // Features: default false. Admin must explicitly enable on the
+ // settings page before the corresponding sidebar entry can show
+ // for any customer.
+ { setting_key: 'customer_feature_calendar_enabled', setting_value: false, setting_type: 'customer_surface' },
+ { setting_key: 'customer_feature_quotes_enabled', setting_value: false, setting_type: 'customer_surface' },
+ { setting_key: 'customer_feature_bills_enabled', setting_value: false, setting_type: 'customer_surface' },
+ // Branding: default true so existing installs keep their current
+ // logo + company name in the customer header until the admin
+ // opts to hide them.
+ { setting_key: 'customer_show_logo', setting_value: true, setting_type: 'customer_surface' },
+ { setting_key: 'customer_show_company_name', setting_value: true, setting_type: 'customer_surface' },
+ ];
+
+ for (const row of seeds) {
+ const existing = await knex('app_settings').where('setting_key', row.setting_key).first();
+ if (!existing) {
+ // Postgres JSONB column accepts both a JSON literal and a
+ // JSON-stringified value depending on driver version. Stringify
+ // for SQLite compatibility; Postgres accepts the same shape.
+ await knex('app_settings').insert({
+ setting_key: row.setting_key,
+ setting_value: JSON.stringify(row.setting_value),
+ setting_type: row.setting_type,
+ });
+ }
+ }
+ }
+
+ // ---- customer_password_resets table -----------------------------------
+
+ if (!(await knex.schema.hasTable('customer_password_resets'))) {
+ await knex.schema.createTable('customer_password_resets', (table) => {
+ table.increments('id').primary();
+ // 64-char hex token, same shape as invitations and admin invites.
+ table.string('token', 64).unique().notNullable();
+ // Always points at an existing customer_account; if the account is
+ // deleted, the reset disappears too.
+ table.integer('customer_account_id').notNullable()
+ .references('id').inTable('customer_accounts').onDelete('CASCADE');
+ table.integer('requested_by_admin_id')
+ .references('id').inTable('admin_users').onDelete('SET NULL');
+ table.timestamp('expires_at').notNullable();
+ table.timestamp('used_at');
+ table.timestamp('created_at').defaultTo(knex.fn.now());
+ table.index('token');
+ table.index('customer_account_id');
+ });
+ }
+
+ // ---- customer_password_reset email template ---------------------------
+
+ if (await knex.schema.hasTable('email_templates')) {
+ const existing = await knex('email_templates').where('template_key', 'customer_password_reset').first();
+ if (!existing) {
+ // Two email_templates schema variants exist in the wild:
+ //
+ // (a) legacy single-locale: subject / body_html / body_text columns
+ // (b) multi-locale: subject_en / subject_de / body_html_en /
+ // body_text_en / ... — all NOT NULL on at least one install
+ // (the maintainer's prod), where the previous version of this
+ // migration silently produced a 23502 NOT NULL violation and
+ // crash-looped the backend.
+ //
+ // Detect whichever variant is present and populate every matching
+ // column. For non-en locale columns we fall back to the English
+ // content so the install isn't left with NULL-violation rows;
+ // proper translations can be filled in later via the admin UI.
+ const cols = await knex('email_templates').columnInfo();
+ const SUBJECT = 'Reset your password';
+ const BODY_HTML = `Hello,
+Your photographer has triggered a password reset for your customer account.
+Click here to set a new password . This link expires on {{expires_at}}.
+If you didn't expect this, you can ignore the message — your current password will keep working until you click the link.
`;
+ const BODY_TEXT = `Your photographer has triggered a password reset for your customer account.\n\nSet a new password: {{reset_link}}\n\nThis link expires on {{expires_at}}.\n\nIf you didn't expect this, you can ignore the message — your current password keeps working until you click the link.`;
+
+ const row = {};
+ if ('template_key' in cols) row.template_key = 'customer_password_reset';
+ if ('language' in cols) row.language = 'en';
+ if ('is_active' in cols) row.is_active = true;
+ if ('created_at' in cols) row.created_at = new Date();
+ if ('updated_at' in cols) row.updated_at = new Date();
+
+ // Populate every subject/body column that exists, regardless of
+ // locale suffix. Fallback content == English; safe because email
+ // templates are user-editable post-install.
+ for (const colName of Object.keys(cols)) {
+ if (colName === 'subject' || /^subject_[a-z]{2,3}$/i.test(colName)) {
+ row[colName] = SUBJECT;
+ } else if (colName === 'body_html' || /^body_html_[a-z]{2,3}$/i.test(colName)) {
+ row[colName] = BODY_HTML;
+ } else if (colName === 'body_text' || /^body_text_[a-z]{2,3}$/i.test(colName)) {
+ row[colName] = BODY_TEXT;
+ }
+ }
+
+ await knex('email_templates').insert(row);
+ }
+ }
+};
+
+exports.down = async function(knex) {
+ // ---- table -----------------------------------------------------------
+ if (await knex.schema.hasTable('customer_password_resets')) {
+ await knex.schema.dropTable('customer_password_resets');
+ }
+
+ // ---- per-customer flags ---------------------------------------------
+ if (await knex.schema.hasTable('customer_accounts')) {
+ const cols = ['feature_calendar', 'feature_quotes', 'feature_bills'];
+ for (const col of cols) {
+ if (await knex.schema.hasColumn('customer_accounts', col)) {
+ await knex.schema.alterTable('customer_accounts', (table) => {
+ table.dropColumn(col);
+ });
+ }
+ }
+ }
+
+ // ---- settings -------------------------------------------------------
+ if (await knex.schema.hasTable('app_settings')) {
+ await knex('app_settings').whereIn('setting_key', [
+ 'customer_feature_calendar_enabled',
+ 'customer_feature_quotes_enabled',
+ 'customer_feature_bills_enabled',
+ 'customer_show_logo',
+ 'customer_show_company_name',
+ ]).del();
+ }
+
+ // ---- template --------------------------------------------------------
+ if (await knex.schema.hasTable('email_templates')) {
+ await knex('email_templates').where('template_key', 'customer_password_reset').del();
+ }
+};
diff --git a/backend/migrations/core/093_customer_feature_default_visible.js b/backend/migrations/core/093_customer_feature_default_visible.js
new file mode 100644
index 00000000..72169a55
--- /dev/null
+++ b/backend/migrations/core/093_customer_feature_default_visible.js
@@ -0,0 +1,63 @@
+/**
+ * Migration: Flip the per-customer feature flag semantic to "opt-out".
+ *
+ * Original semantic (089): both global toggle AND per-customer flag had
+ * to be ON for a customer to see Calendar/Quotes/Bills. Defaults: false.
+ * Result: enabling a feature globally was a no-op until the admin clicked
+ * through every customer detail page and toggled them on individually.
+ *
+ * New semantic (this migration): global toggle is the master; per-customer
+ * flag defaults to TRUE and only acts as an override-to-hide. So:
+ *
+ * - Global ON, per-customer default (true) → visible
+ * - Global ON, per-customer set to false → hidden for this customer
+ * - Global OFF, per-customer anything → hidden (master wins)
+ *
+ * Migration steps:
+ * 1. Update column defaults to true so new customer_accounts rows
+ * auto-opt-in.
+ * 2. Flip every existing row's feature_* columns from false → true.
+ * Rows that were never touched (i.e. ALL of them at this stage in
+ * dev) end up with the new default. If a maintainer had already
+ * hand-toggled a customer to false to hide a feature, that's
+ * indistinguishable from the seeded default at this layer — so
+ * this migration deliberately overwrites. Acceptable because the
+ * original semantic only shipped for one image and nobody is
+ * relying on hand-set false values yet.
+ *
+ * Idempotent: re-running is a no-op (the UPDATE just confirms current
+ * values).
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('customer_accounts'))) return;
+
+ // Step 1 — change defaults. Knex's .alter() rewrites the column;
+ // we keep notNullable to match 089.
+ await knex.schema.alterTable('customer_accounts', (table) => {
+ table.boolean('feature_calendar').notNullable().defaultTo(true).alter();
+ table.boolean('feature_quotes').notNullable().defaultTo(true).alter();
+ table.boolean('feature_bills').notNullable().defaultTo(true).alter();
+ });
+
+ // Step 2 — flip existing rows so they pick up the new default. Without
+ // this, customers created on the 089-shipped image stay invisible even
+ // after the admin enables the feature globally.
+ await knex('customer_accounts').update({
+ feature_calendar: true,
+ feature_quotes: true,
+ feature_bills: true,
+ });
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('customer_accounts'))) return;
+ // Restore the 089 default of false. Don't bulk-update existing rows
+ // back to false: that would silently hide features for customers the
+ // admin had explicitly enabled post-090.
+ await knex.schema.alterTable('customer_accounts', (table) => {
+ table.boolean('feature_calendar').notNullable().defaultTo(false).alter();
+ table.boolean('feature_quotes').notNullable().defaultTo(false).alter();
+ table.boolean('feature_bills').notNullable().defaultTo(false).alter();
+ });
+};
diff --git a/backend/migrations/core/094_customer_invitation_email_themed_button.js b/backend/migrations/core/094_customer_invitation_email_themed_button.js
new file mode 100644
index 00000000..8894e16b
--- /dev/null
+++ b/backend/migrations/core/094_customer_invitation_email_themed_button.js
@@ -0,0 +1,90 @@
+/**
+ * Migration: Re-theme the customer_invitation email's CTA button.
+ *
+ * The original 087 seed inlined `background-color: #5C8762` on the
+ * "Set up your account" anchor, which locked the button to the legacy
+ * green regardless of the admin's `email_primary_color` setting
+ * (Settings → Branding → Email palette). The wrapper template
+ * (emailProcessor.wrapEmailHtml) already exposes a `.button` class
+ * that inherits the configured palette — switching the anchor over
+ * is a one-line change, but existing installs already have the bad
+ * HTML in their email_template_translations rows. This migration
+ * rewrites those rows so the next outbound invitation picks up the
+ * brand colour.
+ *
+ * Idempotent: only updates rows whose stored body still contains the
+ * old hardcoded anchor markup. If the admin has hand-edited the
+ * template (typical for non-English locales they translated
+ * themselves) the row is left alone.
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('email_templates'))) return;
+ if (!(await knex.schema.hasTable('email_template_translations'))) return;
+
+ const master = await knex('email_templates')
+ .where('template_key', 'customer_invitation')
+ .first();
+ if (!master) return; // 087 hasn't run on this install — nothing to fix.
+
+ // English translation
+ const enRow = await knex('email_template_translations')
+ .where({ template_id: master.id, language: 'en' })
+ .first();
+ if (enRow && typeof enRow.body_html === 'string'
+ && enRow.body_html.includes('background-color: #5C8762')
+ && enRow.body_html.includes('Set up your account')) {
+ const newHtml = `
+Welcome to your photo galleries
+You've been invited to create a customer account so you can view all of your event galleries in one place — no more juggling separate links and passwords.
+
+This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:
+{{invite_link}}
+If you weren't expecting this email, you can safely ignore it.
`;
+ await knex('email_template_translations')
+ .where({ id: enRow.id })
+ .update({ body_html: newHtml, updated_at: new Date() });
+ }
+
+ // German translation
+ const deRow = await knex('email_template_translations')
+ .where({ template_id: master.id, language: 'de' })
+ .first();
+ if (deRow && typeof deRow.body_html === 'string'
+ && deRow.body_html.includes('background-color: #5C8762')
+ && deRow.body_html.includes('Konto einrichten')) {
+ const newHtml = `
+Willkommen bei Ihren Fotogalerien
+Sie wurden eingeladen, ein Kundenkonto anzulegen, damit Sie alle Ihre Eventgalerien an einem Ort einsehen können — ohne mehrere Links und Passwörter verwalten zu müssen.
+
+Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:
+{{invite_link}}
+Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.
`;
+ await knex('email_template_translations')
+ .where({ id: deRow.id })
+ .update({ body_html: newHtml, updated_at: new Date() });
+ }
+
+ // Legacy single-language column on the master row, if present.
+ // Older installs may also have a hardcoded body_html on email_templates
+ // itself (pre-translations table). Same idempotent rewrite logic.
+ if (typeof master.body_html === 'string'
+ && master.body_html.includes('background-color: #5C8762')) {
+ await knex('email_templates')
+ .where({ id: master.id })
+ .update({
+ body_html: 'You\'ve been invited to create a customer account. Set up your account (expires {{expires_at}}).
',
+ updated_at: new Date(),
+ });
+ }
+};
+
+exports.down = async function(/* knex */) {
+ // No-op: rolling back the visual fix would intentionally restore the
+ // bug. Admins who want the old green button can edit the template
+ // from Settings → Email Templates.
+};
diff --git a/backend/migrations/core/095_add_customer_portal_flag.js b/backend/migrations/core/095_add_customer_portal_flag.js
new file mode 100644
index 00000000..c1c36e5d
--- /dev/null
+++ b/backend/migrations/core/095_add_customer_portal_flag.js
@@ -0,0 +1,47 @@
+/**
+ * Migration 094: Add `customerPortal` to feature_flags.
+ *
+ * The customer portal (#354) is the foundation feature for the
+ * customer-side UI surface — login, dashboard, profile, password reset,
+ * and the admin Customers management page. Subordinate flags
+ * (calendar, calendarBooking, quotes, bills, messaging) are already
+ * present in the table from migration 088 and gate the customer-side
+ * tabs that hang off the dashboard.
+ *
+ * Default seeding rule mirrors 088:
+ * - Existing install (events table has rows) → customerPortal = TRUE.
+ * The PR ships with the customer-portal foundation already wired,
+ * so an admin who upgrades shouldn't see admin sidebar entries
+ * vanish until they explicitly opt out from Settings → Features.
+ * - Fresh install (no events) → customerPortal = FALSE. Picpeak still
+ * ships as a focused gallery delivery tool by default; admins flip
+ * this on when they want recurring-customer logins.
+ *
+ * Idempotent: skips the insert when the row already exists. Re-running
+ * is a no-op.
+ */
+
+exports.up = async function(knex) {
+ if (!(await knex.schema.hasTable('feature_flags'))) return;
+
+ const existing = await knex('feature_flags').where({ key: 'customerPortal' }).first();
+ if (existing) return;
+
+ // Same existing-vs-fresh detection 088 uses — count events. The flag
+ // table is shared with 088's seeded keys; re-running detection keeps
+ // each new feature flag in lockstep with the install state instead
+ // of guessing per migration.
+ const eventCountRow = await knex('events').count({ count: '*' }).first();
+ const eventCount = parseInt(eventCountRow?.count || 0, 10);
+ const isExistingInstall = eventCount > 0;
+
+ await knex('feature_flags').insert({
+ key: 'customerPortal',
+ value: isExistingInstall,
+ });
+};
+
+exports.down = async function(knex) {
+ if (!(await knex.schema.hasTable('feature_flags'))) return;
+ await knex('feature_flags').where({ key: 'customerPortal' }).del();
+};
diff --git a/backend/server.js b/backend/server.js
index aafa81ae..a56f48da 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -555,7 +555,6 @@ 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/feature-flags', require('./src/routes/adminFeatureFlags'));
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'));
@@ -568,6 +567,21 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
+// Customer-portal feature gate (#354 follow-up). When the master
+// toggle in Settings → Advanced features is OFF, the customer surface
+// returns 410 Gone for end-user routes and 403 for admin-side
+// management routes. The gate fires before the route handler so we
+// don't pay for auth checks against a disabled feature.
+const {
+ requireCustomerPortalEnabled,
+ requireCustomerPortalEnabledAdmin,
+} = require('./src/middleware/requireCustomerPortal');
+
+app.use('/api/admin/customers', requireCustomerPortalEnabledAdmin, require('./src/routes/adminCustomers'));
+// Customer-side surface (#354). Strictly separate from /api/admin/* —
+// distinct token type, distinct cookie, distinct middleware.
+app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth'));
+app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
diff --git a/backend/src/__tests__/customerAccountsService.test.js b/backend/src/__tests__/customerAccountsService.test.js
new file mode 100644
index 00000000..42380a74
--- /dev/null
+++ b/backend/src/__tests__/customerAccountsService.test.js
@@ -0,0 +1,201 @@
+/**
+ * Unit tests for customerAccountsService (#354).
+ *
+ * The service touches the DB in most call sites, so we mock the knex
+ * builder. The point of these tests is to catch the assignment-diff
+ * logic and the invitation guards — not to integration-test knex.
+ */
+
+// --- mocks --------------------------------------------------------------
+jest.mock('../database/db', () => {
+ const mockDb = jest.fn();
+ mockDb.transaction = jest.fn(async (fn) => fn(mockDb));
+ return { db: mockDb, logActivity: jest.fn() };
+});
+jest.mock('../utils/logger', () => ({
+ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
+}));
+jest.mock('../services/emailProcessor', () => ({
+ queueEmail: jest.fn().mockResolvedValue(undefined),
+}));
+jest.mock('../utils/passwordValidation', () => ({
+ getBcryptRounds: () => 4, // fast for tests
+}));
+// frontendUrl is resolved against app_settings; mock the helper directly
+// so the test doesn't need to also stub the settings query.
+jest.mock('../utils/frontendUrl', () => ({
+ getFrontendBaseUrl: jest.fn().mockResolvedValue('https://example.test'),
+}));
+jest.mock('../utils/dbCompat', () => ({
+ formatBoolean: (v) => (v ? 1 : 0),
+}));
+
+const { db } = require('../database/db');
+const { queueEmail } = require('../services/emailProcessor');
+
+// Helper to make a chainable query builder mock that resolves to `result`.
+const chain = (result) => {
+ const q = {};
+ ['where', 'whereNull', 'whereNot', 'whereRaw', 'whereIn', 'andWhere',
+ 'select', 'leftJoin', 'join', 'orderBy', 'limit', 'groupBy', 'first']
+ .forEach((m) => { q[m] = jest.fn().mockReturnValue(q); });
+ q.first = jest.fn().mockResolvedValue(result?.first);
+ q.del = jest.fn().mockResolvedValue(result?.del ?? 0);
+ // returning() must itself return a thenable that resolves to the
+ // configured insert result, since the service awaits it directly.
+ q.insert = jest.fn().mockImplementation(() => {
+ const promise = Promise.resolve(result?.insert ?? []);
+ promise.returning = () => Promise.resolve(result?.insert ?? []);
+ return promise;
+ });
+ q.pluck = jest.fn().mockResolvedValue(result?.pluck ?? []);
+ q.update = jest.fn().mockResolvedValue(result?.update ?? 0);
+ q.then = (resolve) => Promise.resolve(result?.rows ?? []).then(resolve);
+ q.catch = () => q;
+ return q;
+};
+
+beforeEach(() => {
+ db.mockReset();
+ queueEmail.mockClear();
+ db.transaction.mockImplementation(async (fn) => fn(db));
+});
+
+// ---- createInvitation --------------------------------------------------
+
+describe('createInvitation', () => {
+ it('rejects when a customer with the email already exists', async () => {
+ const svc = require('../services/customerAccountsService');
+ db.mockImplementationOnce(() => chain({ first: { id: 1, email: 'taken@example.com' } }));
+ await expect(
+ svc.createInvitation({ email: 'taken@example.com', invitedById: 9 })
+ ).rejects.toThrow(/already exists/i);
+ expect(queueEmail).not.toHaveBeenCalled();
+ });
+
+ it('rejects when a non-expired pending invitation exists', async () => {
+ const svc = require('../services/customerAccountsService');
+ db.mockImplementationOnce(() => chain({ first: null })); // no customer
+ db.mockImplementationOnce(() => chain({ first: { id: 5, email: 'pending@example.com' } }));
+ await expect(
+ svc.createInvitation({ email: 'pending@example.com', invitedById: 9 })
+ ).rejects.toThrow(/pending invitation/i);
+ });
+
+ it('queues an invitation email on success', async () => {
+ const svc = require('../services/customerAccountsService');
+ db.mockImplementationOnce(() => chain({ first: null })); // no customer
+ db.mockImplementationOnce(() => chain({ first: null })); // no pending
+ db.mockImplementationOnce(() => chain({ insert: [{ id: 42 }] })); // insert invitation
+
+ const result = await svc.createInvitation({
+ email: 'new@example.com',
+ invitedById: 9,
+ });
+
+ expect(result.email).toBe('new@example.com');
+ expect(result.token).toMatch(/^[a-f0-9]{64}$/);
+ expect(queueEmail).toHaveBeenCalledTimes(1);
+ const call = queueEmail.mock.calls[0];
+ expect(call[2]).toBe('customer_invitation');
+ expect(call[3].invite_link).toMatch(/\/customer\/invite\//);
+ // Link must honour the configured frontend URL (Site Settings →
+ // general_site_url, surfaced via getFrontendBaseUrl). Mocked above
+ // to https://example.test — the dev-day bug was the link always
+ // emitting localhost regardless of config.
+ expect(call[3].invite_link.startsWith('https://example.test/')).toBe(true);
+ });
+});
+
+// ---- setAssignmentsForEvent --------------------------------------------
+
+describe('setAssignmentsForEvent', () => {
+ it('inserts only customers that are missing and removes those not in the wanted list', async () => {
+ const svc = require('../services/customerAccountsService');
+ // existing assignments: customers 1 and 2
+ const existingChain = chain({ rows: [
+ { id: 100, customer_account_id: 1 },
+ { id: 101, customer_account_id: 2 },
+ ] });
+ // delete chain
+ const deleteChain = chain({ del: 1 });
+ // validity check chain — returns valid ids 3 only (99 is filtered out)
+ const validityChain = chain({ pluck: [3] });
+ // insert chain
+ const insertChain = chain({ insert: [] });
+
+ db.mockImplementationOnce(() => existingChain);
+ db.mockImplementationOnce(() => deleteChain);
+ db.mockImplementationOnce(() => validityChain);
+ db.mockImplementationOnce(() => insertChain);
+
+ const summary = await svc.setAssignmentsForEvent(42, [2, 3, 99], 7);
+
+ // Should remove customer 1 (not in wanted) and only insert valid ones.
+ expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [100]);
+ expect(insertChain.insert).toHaveBeenCalledWith([{
+ event_id: 42,
+ customer_account_id: 3,
+ assigned_by_admin_id: 7,
+ assigned_at: expect.any(Date),
+ }]);
+ // `added` counts attempted-additions before the validity filter — so
+ // 3 and 99 were both attempted (added: 2). The validity filter drops
+ // 99 silently (logged as a warning) before the insert. This matches
+ // the service contract; the test is asserting on it explicitly so a
+ // future refactor can't quietly change it.
+ expect(summary).toEqual({ added: 2, removed: 1 });
+ });
+
+ it('clears all assignments when wanted list is empty', async () => {
+ const svc = require('../services/customerAccountsService');
+ const existingChain = chain({ rows: [
+ { id: 100, customer_account_id: 1 },
+ { id: 101, customer_account_id: 2 },
+ ] });
+ const deleteChain = chain({ del: 2 });
+
+ db.mockImplementationOnce(() => existingChain);
+ db.mockImplementationOnce(() => deleteChain);
+
+ const summary = await svc.setAssignmentsForEvent(42, [], 7);
+ expect(deleteChain.whereIn).toHaveBeenCalledWith('id', [100, 101]);
+ expect(summary).toEqual({ added: 0, removed: 2 });
+ });
+
+ it('is a no-op when wanted equals existing', async () => {
+ const svc = require('../services/customerAccountsService');
+ const existingChain = chain({ rows: [
+ { id: 100, customer_account_id: 1 },
+ ] });
+ db.mockImplementationOnce(() => existingChain);
+
+ const summary = await svc.setAssignmentsForEvent(42, [1], 7);
+ // Only the existing-rows query was called; no del or insert chain
+ // was needed because both diffs are empty.
+ expect(db).toHaveBeenCalledTimes(1);
+ expect(summary).toEqual({ added: 0, removed: 0 });
+ });
+});
+
+// ---- customerHasAccessToEvent ------------------------------------------
+
+describe('customerHasAccessToEvent', () => {
+ it('returns true when an assignment row exists', async () => {
+ const svc = require('../services/customerAccountsService');
+ const c = chain({ first: { id: 99 } });
+ db.mockImplementationOnce(() => c);
+
+ const result = await svc.customerHasAccessToEvent(1, 2);
+ expect(result).toBe(true);
+ expect(c.where).toHaveBeenCalledWith('customer_account_id', 1);
+ expect(c.where).toHaveBeenCalledWith('event_id', 2);
+ });
+
+ it('returns false when no assignment row exists', async () => {
+ const svc = require('../services/customerAccountsService');
+ db.mockImplementationOnce(() => chain({ first: undefined }));
+ const result = await svc.customerHasAccessToEvent(1, 999);
+ expect(result).toBe(false);
+ });
+});
diff --git a/backend/src/__tests__/customerAuth.middleware.test.js b/backend/src/__tests__/customerAuth.middleware.test.js
new file mode 100644
index 00000000..e39b9dde
--- /dev/null
+++ b/backend/src/__tests__/customerAuth.middleware.test.js
@@ -0,0 +1,409 @@
+/**
+ * Unit tests for customerAuth middleware (#354 follow-up).
+ *
+ * Mirrors the parity-with-adminAuth invariants the maintainer flagged
+ * during the PR #403 review:
+ * - Issuer-claim verify
+ * - Token revocation lookup
+ * - Wrong-token-type rejection
+ * - Missing-customer / inactive-customer rejection
+ * - password_changed_at invalidation
+ * - IP drift logged but not rejected
+ *
+ * The middleware reaches into the DB, the JWT verifier, the revocation
+ * cache and the cookie helper — all four are mocked so this stays a
+ * fast unit test (no postgres, no real JWTs).
+ */
+
+// --- mocks --------------------------------------------------------------
+jest.mock('../database/db', () => {
+ const mockDb = jest.fn();
+ return { db: mockDb, logActivity: jest.fn() };
+});
+
+jest.mock('../utils/logger', () => ({
+ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
+}));
+
+jest.mock('jsonwebtoken', () => ({
+ verify: jest.fn(),
+}));
+
+jest.mock('../utils/tokenRevocation', () => ({
+ isTokenRevoked: jest.fn(),
+}));
+
+jest.mock('../utils/tokenUtils', () => ({
+ getCustomerTokenFromRequest: jest.fn(),
+}));
+
+jest.mock('../utils/dbCompat', () => ({
+ formatBoolean: (v) => (v ? 1 : 0),
+}));
+
+const jwt = require('jsonwebtoken');
+const { db } = require('../database/db');
+const { isTokenRevoked } = require('../utils/tokenRevocation');
+const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
+const logger = require('../utils/logger');
+const { customerAuth } = require('../middleware/customerAuth');
+
+// Helper: build a minimal Express-shaped req/res/next trio. The
+// middleware reads req.headers, req.cookies, req.ip etc.; res.status()
+// returns res so the .json() chain works; next is a jest fn so we can
+// assert it was/wasn't called.
+function makeRes() {
+ const res = {};
+ res.status = jest.fn().mockReturnValue(res);
+ res.json = jest.fn().mockReturnValue(res);
+ return res;
+}
+function makeReq({ token = 'tkn', cookies = {}, headers = {}, originalUrl = '/api/customer/foo', ip = '1.2.3.4' } = {}) {
+ return { headers: { authorization: undefined, ...headers }, cookies, originalUrl, ip, connection: { remoteAddress: ip } };
+}
+
+// Convenience: mock db('customer_accounts').where(...).select(...).first()
+// to return the given row. The middleware uses `.where().select().first()`.
+function mockCustomerLookup(row) {
+ const q = {};
+ q.where = jest.fn().mockReturnValue(q);
+ q.select = jest.fn().mockReturnValue(q);
+ q.first = jest.fn().mockResolvedValue(row);
+ db.mockImplementationOnce(() => q);
+ return q;
+}
+
+beforeEach(() => {
+ db.mockReset();
+ jwt.verify.mockReset();
+ isTokenRevoked.mockReset();
+ getCustomerTokenFromRequest.mockReset();
+ logger.info.mockClear();
+ logger.warn.mockClear();
+ logger.debug.mockClear();
+ logger.error.mockClear();
+});
+
+// ---- no token ----------------------------------------------------------
+
+describe('customerAuth — no token', () => {
+ it('returns 401 with NO_TOKEN code when the helper returns null', async () => {
+ getCustomerTokenFromRequest.mockReturnValue(null);
+ const req = makeReq();
+ const res = makeRes();
+ const next = jest.fn();
+
+ await customerAuth(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'NO_TOKEN' }),
+ );
+ // Maintainer-flagged: must be debug-level for unauthenticated probes.
+ // (info-level was the prod-noise bug.)
+ expect(logger.info).not.toHaveBeenCalled();
+ expect(logger.warn).not.toHaveBeenCalled();
+ });
+});
+
+// ---- JWT verification --------------------------------------------------
+
+describe('customerAuth — JWT verification', () => {
+ it('returns 401 TOKEN_EXPIRED when the JWT is expired', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ const err = new Error('jwt expired');
+ err.name = 'TokenExpiredError';
+ jwt.verify.mockImplementation(() => { throw err; });
+
+ const res = makeRes();
+ const next = jest.fn();
+ await customerAuth(makeReq(), res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'TOKEN_EXPIRED' }));
+ });
+
+ it('returns 401 JWT_INVALID on any other JWT error', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ const err = new Error('invalid signature');
+ err.name = 'JsonWebTokenError';
+ jwt.verify.mockImplementation(() => { throw err; });
+
+ const res = makeRes();
+ const next = jest.fn();
+ await customerAuth(makeReq(), res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'JWT_INVALID' }));
+ });
+
+ it('passes the issuer claim to jwt.verify', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ // Make jwt.verify succeed with a customer payload so the test gets
+ // past the verify step; we only care that the call shape is right.
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 1, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ mockCustomerLookup({
+ id: 1, email: 'c@example.com', display_name: null,
+ first_name: null, last_name: null,
+ password_changed_at: null, preferred_language: 'en',
+ });
+
+ await customerAuth(makeReq(), makeRes(), jest.fn());
+
+ expect(jwt.verify).toHaveBeenCalledWith(
+ 'tkn',
+ expect.anything(),
+ expect.objectContaining({ issuer: 'picpeak-auth', complete: true }),
+ );
+ });
+});
+
+// ---- revocation --------------------------------------------------------
+
+describe('customerAuth — revocation', () => {
+ it('rejects revoked tokens with TOKEN_REVOKED', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 1, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(true);
+
+ const res = makeRes();
+ const next = jest.fn();
+ await customerAuth(makeReq(), res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'TOKEN_REVOKED' }));
+ });
+});
+
+// ---- wrong token type --------------------------------------------------
+
+describe('customerAuth — token type', () => {
+ it('rejects an admin token with WRONG_TOKEN_TYPE', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'admin', id: 99, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+
+ const res = makeRes();
+ const next = jest.fn();
+ await customerAuth(makeReq(), res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'WRONG_TOKEN_TYPE' }));
+ });
+
+ it('rejects a gallery token with WRONG_TOKEN_TYPE', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'gallery', eventId: 1, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+
+ const res = makeRes();
+ const next = jest.fn();
+ await customerAuth(makeReq(), res, next);
+
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'WRONG_TOKEN_TYPE' }));
+ });
+});
+
+// ---- customer existence + active check ---------------------------------
+
+describe('customerAuth — customer lookup', () => {
+ it('rejects when the customer row is missing', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 1, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ mockCustomerLookup(null); // not found
+
+ const res = makeRes();
+ const next = jest.fn();
+ await customerAuth(makeReq(), res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'CUSTOMER_NOT_FOUND' }));
+ });
+
+ it('rejects when the customer is inactive (the where-clause filters them out)', async () => {
+ // Active filter is part of the query (.where({ ..., is_active: true })),
+ // so an inactive customer surfaces as a missing row — same code path
+ // as CUSTOMER_NOT_FOUND. This test guards the active filter itself
+ // by asserting the where call shape.
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 1, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ const q = mockCustomerLookup(null);
+
+ await customerAuth(makeReq(), makeRes(), jest.fn());
+
+ expect(q.where).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 1, is_active: 1 }),
+ );
+ });
+});
+
+// ---- password_changed_at invalidation ----------------------------------
+
+describe('customerAuth — password_changed_at', () => {
+ it('rejects tokens issued before password_changed_at with PASSWORD_CHANGED', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ // Token issued at unix 1000; password changed at 2000.
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 1, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ mockCustomerLookup({
+ id: 1, email: 'c@example.com', display_name: null,
+ first_name: null, last_name: null,
+ password_changed_at: new Date(2000 * 1000), // unix 2000
+ preferred_language: 'en',
+ });
+
+ const res = makeRes();
+ const next = jest.fn();
+ await customerAuth(makeReq(), res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(401);
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'PASSWORD_CHANGED' }));
+ });
+
+ it('accepts tokens issued at exactly password_changed_at', async () => {
+ // Boundary: iat === passwordChangedSeconds → the strict-less-than
+ // check should NOT reject. Token is still valid in this edge case.
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 1, iat: 2000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ mockCustomerLookup({
+ id: 1, email: 'c@example.com', display_name: null,
+ first_name: null, last_name: null,
+ password_changed_at: new Date(2000 * 1000),
+ preferred_language: 'en',
+ });
+
+ const next = jest.fn();
+ await customerAuth(makeReq(), makeRes(), next);
+
+ expect(next).toHaveBeenCalled();
+ });
+
+ it('accepts tokens when password_changed_at is null', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 1, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ mockCustomerLookup({
+ id: 1, email: 'c@example.com', display_name: null,
+ first_name: null, last_name: null,
+ password_changed_at: null,
+ preferred_language: 'en',
+ });
+
+ const next = jest.fn();
+ await customerAuth(makeReq(), makeRes(), next);
+
+ expect(next).toHaveBeenCalled();
+ });
+});
+
+// ---- IP drift ----------------------------------------------------------
+
+describe('customerAuth — IP drift', () => {
+ it('logs but does not reject when token IP differs from request IP', async () => {
+ // Mirrors adminAuth: customers may roam between mobile networks
+ // mid-session, so IP drift is a log-and-continue, not a denial.
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 1, iat: 1000, ip: '8.8.8.8' },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ mockCustomerLookup({
+ id: 1, email: 'c@example.com', display_name: null,
+ first_name: null, last_name: null,
+ password_changed_at: null,
+ preferred_language: 'en',
+ });
+
+ const next = jest.fn();
+ await customerAuth(makeReq({ ip: '4.4.4.4' }), makeRes(), next);
+
+ expect(next).toHaveBeenCalled();
+ // The drift line uses logger.info on adminAuth and customerAuth;
+ // we don't assert level here, just that something was logged.
+ expect(logger.info).toHaveBeenCalled();
+ });
+});
+
+// ---- happy path --------------------------------------------------------
+
+describe('customerAuth — happy path', () => {
+ it('attaches req.customer and calls next() on a valid token', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 7, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ mockCustomerLookup({
+ id: 7,
+ email: 'c@example.com',
+ display_name: 'Charlie',
+ first_name: 'Charlie',
+ last_name: 'Customer',
+ password_changed_at: null,
+ preferred_language: 'de',
+ });
+
+ const req = makeReq();
+ const next = jest.fn();
+ await customerAuth(req, makeRes(), next);
+
+ expect(next).toHaveBeenCalled();
+ expect(req.customer).toEqual({
+ id: 7,
+ email: 'c@example.com',
+ displayName: 'Charlie',
+ firstName: 'Charlie',
+ lastName: 'Customer',
+ preferredLanguage: 'de',
+ });
+ expect(req.token).toBe('tkn');
+ });
+
+ it('defaults preferredLanguage to en when the column is null', async () => {
+ getCustomerTokenFromRequest.mockReturnValue('tkn');
+ jwt.verify.mockReturnValue({
+ payload: { type: 'customer', customerId: 7, iat: 1000 },
+ });
+ isTokenRevoked.mockResolvedValue(false);
+ mockCustomerLookup({
+ id: 7, email: 'c@example.com',
+ display_name: null, first_name: null, last_name: null,
+ password_changed_at: null,
+ preferred_language: null,
+ });
+
+ const req = makeReq();
+ await customerAuth(req, makeRes(), jest.fn());
+
+ expect(req.customer.preferredLanguage).toBe('en');
+ });
+});
diff --git a/backend/src/database/db.js b/backend/src/database/db.js
index b9cc15eb..910966ef 100644
--- a/backend/src/database/db.js
+++ b/backend/src/database/db.js
@@ -554,11 +554,23 @@ async function ensureGlobalCategories() {
// Helper function to log activities
async function logActivity(activityType, metadata = {}, eventId = null, actor = null) {
try {
+ // actor_id is integer-typed; some legacy callers pass a hex-string
+ // identifier (e.g. a 16-char guest fingerprint) which makes Postgres
+ // throw "invalid input syntax for type integer" and drop the entire
+ // log entry. Coerce anything non-integer to null and surface the
+ // string in actor_name so we don't lose the audit trail. Customer/
+ // admin actors are unaffected — their ids are already numeric.
+ const rawId = actor?.id;
+ const actorIdInt = Number.isInteger(rawId) ? rawId
+ : (typeof rawId === 'string' && /^\d+$/.test(rawId) ? Number(rawId) : null);
+ const actorName = actor?.name
+ || (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null);
+
await db('activity_logs').insert({
activity_type: activityType,
actor_type: actor?.type || 'system',
- actor_id: actor?.id || null,
- actor_name: actor?.name || null,
+ actor_id: actorIdInt,
+ actor_name: actorName,
metadata: JSON.stringify(metadata),
event_id: eventId
});
diff --git a/backend/src/middleware/customerAuth.js b/backend/src/middleware/customerAuth.js
new file mode 100644
index 00000000..1d2e717b
--- /dev/null
+++ b/backend/src/middleware/customerAuth.js
@@ -0,0 +1,131 @@
+/**
+ * Customer Authentication Middleware
+ *
+ * Verifies a 'customer' JWT issued by /api/customer/auth/login. Mirrors
+ * adminAuth (same revocation, IP-log, password-change invalidation flow)
+ * but operates on customer_accounts rather than admin_users — so an
+ * admin token cannot pass as a customer and vice versa.
+ *
+ * Sets `req.customer = { id, email, displayName, isActive }` on success.
+ */
+
+const jwt = require('jsonwebtoken');
+const { db } = require('../database/db');
+const { formatBoolean } = require('../utils/dbCompat');
+const { isTokenRevoked } = require('../utils/tokenRevocation');
+const logger = require('../utils/logger');
+const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
+
+async function customerAuth(req, res, next) {
+ try {
+ const token = getCustomerTokenFromRequest(req);
+ if (!token) {
+ // Quiet by default — unauthenticated /api/customer/* requests are
+ // normal (page polling, pre-login session probes). Bump to debug
+ // for noisy investigations only.
+ logger.debug('[customerAuth] no token on request', {
+ url: req.originalUrl,
+ hasCookieHeader: !!req.headers?.cookie,
+ cookieKeys: Object.keys(req.cookies || {}),
+ });
+ return res.status(401).json({ error: 'No token provided', code: 'NO_TOKEN' });
+ }
+
+ let decoded;
+ try {
+ const verified = jwt.verify(token, process.env.JWT_SECRET, {
+ issuer: 'picpeak-auth',
+ complete: true,
+ });
+ decoded = verified.payload;
+ } catch (err) {
+ logger.warn('[customerAuth] jwt verification failed', {
+ url: req.originalUrl,
+ errorName: err.name,
+ errorMessage: err.message,
+ });
+ if (err.name === 'TokenExpiredError') {
+ return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
+ }
+ return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' });
+ }
+
+ if (await isTokenRevoked(decoded)) {
+ logger.warn('[customerAuth] token revoked', {
+ url: req.originalUrl,
+ customerId: decoded.customerId,
+ tokenType: decoded.type,
+ iat: decoded.iat,
+ });
+ return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
+ }
+
+ if (decoded.type !== 'customer') {
+ logger.warn('[customerAuth] wrong token type', {
+ url: req.originalUrl,
+ tokenType: decoded.type,
+ });
+ return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' });
+ }
+
+ // IP drift gets logged but doesn't reject — same lenient policy as
+ // adminAuth. Customers may roam between mobile networks frequently.
+ const currentIp = req.ip || req.connection.remoteAddress;
+ if (decoded.ip && decoded.ip !== currentIp) {
+ logger.info('Customer token used from different IP', {
+ customerId: decoded.customerId,
+ tokenIp: decoded.ip,
+ currentIp,
+ });
+ }
+
+ const customer = await db('customer_accounts')
+ .where({ id: decoded.customerId, is_active: formatBoolean(true) })
+ .select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language')
+ .first();
+
+ if (!customer) {
+ // Either deleted, deactivated, or the id was forged. 401 across the
+ // board so the frontend session-expiry handler kicks in.
+ logger.warn('[customerAuth] customer row not found / inactive', {
+ url: req.originalUrl,
+ customerId: decoded.customerId,
+ });
+ return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' });
+ }
+
+ if (customer.password_changed_at) {
+ const passwordChangedSeconds = Math.floor(
+ new Date(customer.password_changed_at).getTime() / 1000
+ );
+ if (decoded.iat < passwordChangedSeconds) {
+ logger.warn('[customerAuth] token rejected: password_changed_at', {
+ url: req.originalUrl,
+ customerId: decoded.customerId,
+ iat: decoded.iat,
+ passwordChangedSeconds,
+ });
+ return res.status(401).json({
+ error: 'Token invalid due to password change',
+ code: 'PASSWORD_CHANGED',
+ });
+ }
+ }
+
+ req.customer = {
+ id: customer.id,
+ email: customer.email,
+ displayName: customer.display_name,
+ firstName: customer.first_name,
+ lastName: customer.last_name,
+ preferredLanguage: customer.preferred_language || 'en',
+ };
+ req.token = token;
+ next();
+ } catch (error) {
+ logger.error('Customer auth middleware error:', error);
+ res.status(401).json({ error: 'Authentication failed' });
+ }
+}
+
+module.exports = { customerAuth };
diff --git a/backend/src/routes/adminCustomers.js b/backend/src/routes/adminCustomers.js
new file mode 100644
index 00000000..07579db8
--- /dev/null
+++ b/backend/src/routes/adminCustomers.js
@@ -0,0 +1,313 @@
+/**
+ * Admin → Customers Routes
+ *
+ * Endpoint mounted at /api/admin/customers (see app.js wiring).
+ * Mirrors adminUsers.js for the invitation lifecycle but operates on
+ * customer_accounts. Customer-side login routes live in customerAuth.js.
+ */
+
+const express = require('express');
+const { body, param, query } = require('express-validator');
+const { adminAuth } = require('../middleware/auth');
+const { requirePermission } = require('../middleware/permissions');
+const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
+const customerAccountsService = require('../services/customerAccountsService');
+
+const router = express.Router();
+
+/**
+ * Snake_case (DB) → camelCase (API). Kept narrow on purpose: only fields
+ * the frontend actually needs land in the response so the surface area
+ * doesn't accidentally grow when new columns get added later.
+ */
+function transformCustomer(c) {
+ return {
+ id: c.id,
+ email: c.email,
+ salutation: c.salutation,
+ firstName: c.first_name,
+ lastName: c.last_name,
+ displayName: c.display_name,
+ phone: c.phone,
+ companyName: c.company_name,
+ billingEmail: c.billing_email,
+ vatId: c.vat_id,
+ addressLine1: c.address_line1,
+ addressLine2: c.address_line2,
+ postalCode: c.postal_code,
+ city: c.city,
+ state: c.state,
+ countryCode: c.country_code,
+ preferredLanguage: c.preferred_language,
+ notes: c.notes,
+ isActive: c.is_active,
+ // Per-customer feature flags (#354 follow-up). Coerce to bool so the
+ // frontend doesn't have to deal with SQLite's 0/1 values.
+ featureCalendar: c.feature_calendar === true || c.feature_calendar === 1,
+ featureQuotes: c.feature_quotes === true || c.feature_quotes === 1,
+ featureBills: c.feature_bills === true || c.feature_bills === 1,
+ lastLogin: c.last_login,
+ createdAt: c.created_at,
+ updatedAt: c.updated_at,
+ eventCount: c.event_count != null ? Number(c.event_count) : undefined,
+ events: Array.isArray(c.events)
+ ? c.events.map((e) => ({
+ id: e.id,
+ slug: e.slug,
+ eventName: e.event_name,
+ eventDate: e.event_date,
+ expiresAt: e.expires_at,
+ isArchived: e.is_archived,
+ assignedAt: e.assigned_at,
+ }))
+ : undefined,
+ };
+}
+
+function transformInvitation(inv) {
+ return {
+ id: inv.id,
+ email: inv.email,
+ expiresAt: inv.expires_at,
+ createdAt: inv.created_at,
+ invitedBy: inv.invited_by,
+ };
+}
+
+// ---- list / search ------------------------------------------------------
+
+router.get('/', [
+ adminAuth,
+ requirePermission('customers.view'),
+ query('search').optional().isString(),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const customers = await customerAccountsService.listCustomers({
+ search: req.query.search,
+ });
+ res.json({ customers: customers.map(transformCustomer) });
+}));
+
+/**
+ * GET /search?email=…
+ *
+ * Autocomplete used by the event-form CustomerAccountPicker. Returns
+ * up to 10 matches against email/name/company prefixes. Permission is
+ * customers.view because exposing emails to anyone with users.view but
+ * not customers.view would leak the customer roster.
+ */
+router.get('/search', [
+ adminAuth,
+ requirePermission('customers.view'),
+ query('email').optional().isString(),
+ query('q').optional().isString(),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const term = req.query.email || req.query.q || '';
+ const results = await customerAccountsService.searchCustomers(term);
+ res.json({ customers: results.map(transformCustomer) });
+}));
+
+// ---- invitations --------------------------------------------------------
+
+router.get('/invitations', [
+ adminAuth,
+ requirePermission('customers.view'),
+], handleAsync(async (req, res) => {
+ const invitations = await customerAccountsService.getPendingInvitations();
+ res.json({ invitations: invitations.map(transformInvitation) });
+}));
+
+router.post('/invite', [
+ adminAuth,
+ requirePermission('customers.create'),
+ body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
+ // Optional prefill — admin can stash any subset of customer profile fields
+ // on the invitation. The customer sees them pre-populated on the accept
+ // form and can edit before submitting. Validators are deliberately lax:
+ // any field can be omitted, and only length is enforced (sanitisation
+ // happens server-side in the service).
+ body('prefill').optional().isObject(),
+ body('prefill.salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
+ body('prefill.first_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
+ body('prefill.last_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
+ body('prefill.display_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('prefill.phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
+ body('prefill.company_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('prefill.vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
+ body('prefill.address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
+ body('prefill.address_line2').optional({ nullable: true }).isString().isLength({ max: 255 }),
+ body('prefill.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
+ body('prefill.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('prefill.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const invitation = await customerAccountsService.createInvitation({
+ email: req.body.email,
+ invitedById: req.admin.id,
+ prefill: req.body.prefill,
+ });
+ // Echo the token in the response ONLY in non-production. This lets
+ // local dev + Playwright e2e specs skip the email round-trip
+ // (queueing → SMTP → mailbox → parse) and accept the invitation
+ // straight away. In production the token stays email-channel-only:
+ // anyone with API access plus the response body would otherwise be
+ // able to take over a freshly-invited customer account before the
+ // legitimate user clicks the link.
+ const payload = {
+ invitation: {
+ id: invitation.id,
+ email: invitation.email,
+ expiresAt: invitation.expiresAt,
+ },
+ };
+ if (process.env.NODE_ENV !== 'production') {
+ payload.invitation.token = invitation.token;
+ }
+ successResponse(res, payload, 201);
+}));
+
+router.delete('/invitations/:id', [
+ adminAuth,
+ requirePermission('customers.create'),
+ param('id').isInt({ min: 1 }),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ await customerAccountsService.cancelInvitation(
+ parseInt(req.params.id, 10),
+ req.admin.id
+ );
+ successResponse(res, { message: 'Invitation cancelled' });
+}));
+
+// ---- customer record ----------------------------------------------------
+
+router.get('/:id', [
+ adminAuth,
+ requirePermission('customers.view'),
+ param('id').isInt({ min: 1 }),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const customer = await customerAccountsService.getCustomerById(
+ parseInt(req.params.id, 10)
+ );
+ res.json({ customer: transformCustomer(customer) });
+}));
+
+router.put('/:id', [
+ adminAuth,
+ requirePermission('customers.create'),
+ param('id').isInt({ min: 1 }),
+ body('email').optional().isEmail().normalizeEmail(),
+ body('salutation').optional().isString().isLength({ max: 32 }),
+ body('first_name').optional().isString().isLength({ max: 80 }),
+ body('last_name').optional().isString().isLength({ max: 80 }),
+ body('display_name').optional().isString().isLength({ max: 120 }),
+ body('phone').optional().isString().isLength({ max: 40 }),
+ body('company_name').optional().isString().isLength({ max: 120 }),
+ body('billing_email').optional({ nullable: true }).isString(),
+ body('vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
+ body('address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
+ body('address_line2').optional({ nullable: true }).isString().isLength({ max: 255 }),
+ body('postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
+ body('city').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
+ body('preferred_language').optional().isString().isLength({ max: 8 }),
+ body('notes').optional({ nullable: true }).isString(),
+ body('is_active').optional().isBoolean(),
+ body('feature_calendar').optional().isBoolean(),
+ body('feature_quotes').optional().isBoolean(),
+ body('feature_bills').optional().isBoolean(),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const customer = await customerAccountsService.updateCustomer(
+ parseInt(req.params.id, 10),
+ req.body,
+ req.admin.id
+ );
+ res.json({ customer: transformCustomer(customer) });
+}));
+
+router.post('/:id/deactivate', [
+ adminAuth,
+ requirePermission('customers.delete'),
+ param('id').isInt({ min: 1 }),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ await customerAccountsService.deactivateCustomer(
+ parseInt(req.params.id, 10),
+ req.admin.id
+ );
+ successResponse(res, { message: 'Customer deactivated' });
+}));
+
+/**
+ * POST /:id/reactivate (#354 follow-up).
+ *
+ * Restore a previously-deactivated customer. Same permission as
+ * deactivate (`customers.delete`) since they're inverse operations and
+ * the admin who can disable should be the one who can re-enable.
+ */
+router.post('/:id/reactivate', [
+ adminAuth,
+ requirePermission('customers.delete'),
+ param('id').isInt({ min: 1 }),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ await customerAccountsService.reactivateCustomer(
+ parseInt(req.params.id, 10),
+ req.admin.id
+ );
+ successResponse(res, { message: 'Customer reactivated' });
+}));
+
+/**
+ * POST /:id/erase (#354 follow-up).
+ *
+ * Anonymize-in-place erasure (GDPR Art. 17 style): nulls every PII
+ * column, wipes credentials, drops pending invitations and reset tokens,
+ * keeps the row + audit references intact so historical "who had access"
+ * queries don't break. See customerAccountsService.eraseCustomer for
+ * the full rationale.
+ *
+ * Hard delete is NOT shipped — `customer_invitations.accepted_customer_id`
+ * has no ON DELETE CASCADE, so a real DELETE would FK-block on any
+ * customer who ever accepted an invitation.
+ */
+router.post('/:id/erase', [
+ adminAuth,
+ requirePermission('customers.delete'),
+ param('id').isInt({ min: 1 }),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ await customerAccountsService.eraseCustomer(
+ parseInt(req.params.id, 10),
+ req.admin.id
+ );
+ successResponse(res, { message: 'Customer erased' });
+}));
+
+/**
+ * POST /:id/password-reset (#354 follow-up).
+ *
+ * Generate a 7-day password-reset token and email it to the customer.
+ * Reused permission `customers.create` because issuing a reset is the
+ * same authority level as issuing an invitation — both put a credential
+ * into the customer's mailbox.
+ */
+router.post('/:id/password-reset', [
+ adminAuth,
+ requirePermission('customers.create'),
+ param('id').isInt({ min: 1 }),
+], handleAsync(async (req, res) => {
+ validateRequest(req);
+ const result = await customerAccountsService.createPasswordReset({
+ customerId: parseInt(req.params.id, 10),
+ requestedByAdminId: req.admin.id,
+ });
+ successResponse(res, { email: result.email, expiresAt: result.expiresAt });
+}));
+
+module.exports = router;
diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js
index c05ee595..e441dab3 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -399,7 +399,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// custom → render this event's promo_markdown verbatim
// off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
- body('promo_markdown').optional({ nullable: true }).isString()
+ body('promo_markdown').optional({ nullable: true }).isString(),
+ // Customer accounts assigned to this event (#354). Optional array of
+ // customer_accounts.id — many-to-many via event_customer_assignments.
+ body('customer_account_ids').optional().isArray(),
+ body('customer_account_ids.*').optional().isInt({ min: 1 })
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -664,7 +668,28 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
-
+
+ // Apply customer-account assignments (#354). Skip when the customer
+ // portal flag is off — the frontend hides the picker in that case,
+ // but a stale tab could still POST customer_account_ids; we ignore
+ // them rather than 403 the entire create.
+ if (Array.isArray(req.body.customer_account_ids)) {
+ try {
+ const customerAccountsService = require('../services/customerAccountsService');
+ if (await customerAccountsService.isCustomerPortalEnabled()) {
+ await customerAccountsService.setAssignmentsForEvent(
+ eventId,
+ req.body.customer_account_ids,
+ req.admin.id
+ );
+ }
+ } catch (e) {
+ logger.error('Failed to set customer assignments on event create', {
+ eventId, error: e.message,
+ });
+ }
+ }
+
// Insert feedback settings if feedback is enabled
if (feedback_enabled) {
await db('event_feedback_settings').insert({
@@ -943,6 +968,17 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
.where('event_id', id)
.countDistinct('ip_address as uniqueVisitors');
+ // Customer accounts assigned to this event (#354). Hydrates the
+ // CustomerAccountPicker on the EventDetailsPage admin form. Returns
+ // an empty array on installs missing the table (e.g. pre-migrate).
+ let customerAccounts = [];
+ try {
+ const customerAccountsService = require('../services/customerAccountsService');
+ customerAccounts = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
+ } catch (e) {
+ logger.warn('Failed to load customer assignments for event', { eventId: id, error: e.message });
+ }
+
res.json(mapEventForApi({
...event,
photo_count: parseInt(photoCount) || 0,
@@ -950,7 +986,14 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
total_views: parseInt(totalViews) || 0,
total_downloads: parseInt(totalDownloads) || 0,
unique_visitors: parseInt(uniqueVisitors) || 0,
- recent_photos: recentPhotos
+ recent_photos: recentPhotos,
+ customer_accounts: customerAccounts.map((c) => ({
+ id: c.id,
+ email: c.email,
+ display_name: c.display_name,
+ first_name: c.first_name,
+ last_name: c.last_name,
+ })),
}));
} catch (error) {
console.error('Error fetching event:', error);
@@ -1112,7 +1155,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
// custom → render this event's promo_markdown verbatim
// off → suppress entirely for this event
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
- body('promo_markdown').optional({ nullable: true }).isString()
+ body('promo_markdown').optional({ nullable: true }).isString(),
+ // Customer accounts assigned to this event (#354). Optional array of
+ // customer_accounts.id — many-to-many via event_customer_assignments.
+ body('customer_account_ids').optional().isArray(),
+ body('customer_account_ids.*').optional().isInt({ min: 1 })
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -1313,6 +1360,26 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
.where('id', id)
.update(updates);
+ // Customer-account assignments (#354). Same skip semantics as POST:
+ // ignore when the customer portal flag is off so stale tabs don't
+ // 4xx the whole edit.
+ if (Array.isArray(req.body.customer_account_ids)) {
+ try {
+ const customerAccountsService = require('../services/customerAccountsService');
+ if (await customerAccountsService.isCustomerPortalEnabled()) {
+ await customerAccountsService.setAssignmentsForEvent(
+ parseInt(id, 10),
+ req.body.customer_account_ids,
+ req.admin.id
+ );
+ }
+ } catch (e) {
+ logger.error('Failed to set customer assignments on event update', {
+ eventId: id, error: e.message,
+ });
+ }
+ }
+
// Log activity
await logActivity('event_updated',
{ changes: Object.keys(updates), eventName: event.event_name },
diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js
index d736b27f..21ea3d06 100644
--- a/backend/src/routes/adminFeatureFlags.js
+++ b/backend/src/routes/adminFeatureFlags.js
@@ -32,6 +32,9 @@ const KNOWN_FLAGS = [
'messaging',
'analytics',
'userManagement',
+ // Foundation flag for the customer-side surface (#354). See migration
+ // 094 for the seeding rule.
+ 'customerPortal',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index fe122e8b..e99b5861 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -476,7 +476,18 @@ router.post('/gallery/logout', async (req, res) => {
router.get('/session', async (req, res) => {
try {
const { slug } = req.query;
- const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
+ // Token precedence: when ?slug= is present the caller is asking
+ // specifically about gallery auth (GalleryAuthContext), so prefer the
+ // gallery token. Without this, an admin who's also dogfooding the
+ // customer dashboard from the same browser would always get
+ // {type:'admin'} back here, the gallery context's
+ // `type === 'gallery'` check would fail, and the page would fall
+ // through to the per-event password prompt — even though the
+ // gallery_token_ cookie was correctly set on the prior
+ // /api/customer/events/:slug/access-token response.
+ const token = slug
+ ? (getGalleryTokenFromRequest(req, slug) || getAdminTokenFromRequest(req))
+ : (getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug));
if (!token) {
return res.status(401).json({ error: 'No token provided' });
diff --git a/backend/src/routes/customer.js b/backend/src/routes/customer.js
new file mode 100644
index 00000000..6e97aa33
--- /dev/null
+++ b/backend/src/routes/customer.js
@@ -0,0 +1,371 @@
+/**
+ * Customer dashboard routes
+ *
+ * Mounted at /api/customer (see server.js). Every endpoint here requires
+ * a valid 'customer' JWT — see middleware/customerAuth.js.
+ *
+ * Endpoints:
+ * GET /events list assigned events for dashboard
+ * GET /events/:slug/access-token mint a gallery JWT so the customer
+ * can browse the event without going
+ * through the per-event password gate
+ */
+
+const express = require('express');
+const bcrypt = require('bcrypt');
+const jwt = require('jsonwebtoken');
+const { body, param, validationResult } = require('express-validator');
+const { db, logActivity } = require('../database/db');
+const { formatBoolean } = require('../utils/dbCompat');
+const { getBcryptRounds } = require('../utils/passwordValidation');
+const logger = require('../utils/logger');
+const { getClientIp } = require('../utils/requestIp');
+const { customerAuth } = require('../middleware/customerAuth');
+const { setGalleryAuthCookies } = require('../utils/tokenUtils');
+const customerAccountsService = require('../services/customerAccountsService');
+
+/**
+ * Customer-side password policy mirrors the one in customerAuth.js — kept
+ * deliberately simple (8 chars, one uppercase, one digit) since a customer
+ * account only sees galleries, never financial or admin surfaces.
+ */
+function validateCustomerPassword(password) {
+ if (typeof password !== 'string' || password.length < 8) {
+ return 'Password must be at least 8 characters long.';
+ }
+ if (!/[A-Z]/.test(password)) return 'Password must contain at least one uppercase letter.';
+ if (!/[0-9]/.test(password)) return 'Password must contain at least one number.';
+ return null;
+}
+
+/**
+ * Camel→snake mapping used by the self-service profile PUT. Same field set
+ * as the admin update endpoint minus is_active (admin-only) and
+ * preferred_language / notes (admin-only metadata, not customer-facing).
+ */
+const PROFILE_FIELD_MAP = {
+ salutation: 'salutation',
+ firstName: 'first_name',
+ lastName: 'last_name',
+ displayName: 'display_name',
+ phone: 'phone',
+ companyName: 'company_name',
+ vatId: 'vat_id',
+ addressLine1: 'address_line1',
+ addressLine2: 'address_line2',
+ postalCode: 'postal_code',
+ city: 'city',
+ state: 'state',
+ countryCode: 'country_code',
+ preferredLanguage: 'preferred_language',
+};
+
+function shapeProfile(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ email: row.email,
+ salutation: row.salutation,
+ firstName: row.first_name,
+ lastName: row.last_name,
+ displayName: row.display_name,
+ phone: row.phone,
+ companyName: row.company_name,
+ vatId: row.vat_id,
+ addressLine1: row.address_line1,
+ addressLine2: row.address_line2,
+ postalCode: row.postal_code,
+ city: row.city,
+ state: row.state,
+ countryCode: row.country_code,
+ preferredLanguage: row.preferred_language || 'en',
+ };
+}
+
+const router = express.Router();
+
+const GALLERY_TOKEN_TTL_SECONDS = 24 * 60 * 60;
+
+// ---- list assigned events ---------------------------------------------
+
+router.get('/events', customerAuth, async (req, res) => {
+ try {
+ const events = await customerAccountsService.listEventsForCustomer(req.customer.id);
+ res.json({
+ events: events.map((e) => ({
+ id: e.id,
+ slug: e.slug,
+ eventName: e.event_name,
+ eventType: e.event_type,
+ eventDate: e.event_date,
+ expiresAt: e.expires_at,
+ isActive: e.is_active,
+ assignedAt: e.assigned_at,
+ })),
+ });
+ } catch (error) {
+ logger.error('Customer event list error:', error);
+ res.status(500).json({ error: 'Failed to load events' });
+ }
+});
+
+// ---- access-token exchange --------------------------------------------
+
+/**
+ * Customer JWT → Gallery JWT exchange.
+ *
+ * The gallery API and frontend already expect a 'gallery' token in the
+ * gallery_token / gallery_token_{slug} cookie. Rather than teach every
+ * gallery code path about a third token type, we mint a fresh gallery
+ * token here when the customer is assigned to the event. The frontend
+ * stores it in the slug-specific cookie via the existing
+ * storeGalleryToken() utility, and from that point on the gallery loads
+ * exactly as if the per-event password had been entered.
+ *
+ * Returns 403 if the customer is not assigned, 404 if the event slug is
+ * unknown, 410 if the event is archived/expired (so the dashboard can
+ * surface a useful "this gallery has expired" message rather than just
+ * an opaque 403).
+ */
+router.get('/events/:slug/access-token', [
+ customerAuth,
+ param('slug').isString().notEmpty(),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ const { slug } = req.params;
+ const event = await db('events').where('slug', slug).first();
+ if (!event) {
+ return res.status(404).json({ error: 'Event not found' });
+ }
+ if (event.is_archived) {
+ return res.status(410).json({ error: 'This gallery has been archived' });
+ }
+ if (event.expires_at && new Date(event.expires_at) < new Date()) {
+ return res.status(410).json({ error: 'This gallery has expired' });
+ }
+
+ const hasAccess = await customerAccountsService.customerHasAccessToEvent(
+ req.customer.id,
+ event.id
+ );
+ if (!hasAccess) {
+ logger.warn('Customer attempted to access unassigned event', {
+ customerId: req.customer.id,
+ eventId: event.id,
+ slug,
+ });
+ return res.status(403).json({ error: 'You do not have access to this gallery' });
+ }
+
+ const ipAddress = getClientIp(req);
+ // Same shape as /api/auth/gallery/verify — keep them in sync so the
+ // gallery middleware (verifyGalleryAccess) doesn't need a code change.
+ const token = jwt.sign({
+ eventId: event.id,
+ eventSlug: event.slug,
+ type: 'gallery',
+ ip: ipAddress,
+ loginTime: Date.now(),
+ // Optional bookkeeping claim — surfaces the originating customer in
+ // logs when the token is later used. Doesn't affect authorization.
+ via: 'customer',
+ customerId: req.customer.id,
+ }, process.env.JWT_SECRET, {
+ expiresIn: GALLERY_TOKEN_TTL_SECONDS,
+ issuer: 'picpeak-auth',
+ });
+
+ // Mirror the cookie-write that /api/auth/gallery/verify performs on
+ // password success. Without this, the freshly-minted token only lives
+ // in the dashboard's sessionStorage; GalleryAuthProvider runs
+ // cleanupOldGalleryAuth() on mount and sweeps every gallery_token_*
+ // sessionStorage key, including the one we just stored. The cookie
+ // (which that cleanup helper does NOT touch when it's slug-scoped)
+ // is what keeps the customer authenticated after navigation, hard
+ // reloads, and tab restores.
+ setGalleryAuthCookies(res, token, event.slug);
+
+ await db('access_logs').insert({
+ event_id: event.id,
+ ip_address: ipAddress,
+ user_agent: req.headers['user-agent'] || '',
+ action: 'login_success',
+ });
+
+ await logActivity('customer_event_access',
+ { customerId: req.customer.id, eventId: event.id, slug },
+ event.id,
+ { type: 'customer', id: req.customer.id, name: req.customer.email }
+ );
+
+ res.json({
+ token,
+ event: {
+ id: event.id,
+ slug: event.slug,
+ eventName: event.event_name,
+ },
+ });
+ } catch (error) {
+ logger.error('Customer access-token exchange error:', error);
+ res.status(500).json({ error: 'Failed to issue access token' });
+ }
+});
+
+// ---- self-service profile ----------------------------------------------
+
+/**
+ * GET /profile
+ *
+ * Returns the full customer profile (everything the customer can edit on
+ * their own profile page). The /auth/session endpoint deliberately stays
+ * narrow — only the fields the layout needs — to keep the auth payload
+ * tight; this endpoint is the canonical "give me everything" read.
+ */
+router.get('/profile', customerAuth, async (req, res) => {
+ try {
+ const row = await db('customer_accounts').where('id', req.customer.id).first();
+ if (!row) {
+ return res.status(404).json({ error: 'Profile not found' });
+ }
+ res.json({ profile: shapeProfile(row) });
+ } catch (error) {
+ logger.error('Customer profile read error:', error);
+ res.status(500).json({ error: 'Failed to load profile' });
+ }
+});
+
+/**
+ * PUT /profile
+ *
+ * Self-service edit. Accepts the same field set as the admin endpoint but
+ * deliberately excludes:
+ * - email (would invalidate the login credential silently)
+ * - is_active (admin-only)
+ * - notes (admin-only metadata)
+ * - billing_email (kept admin-managed for now; we'll surface it later
+ * when the quotes/bills flows actually need a separate
+ * billing contact)
+ * - password_hash (separate /profile/password endpoint)
+ */
+router.put('/profile', [
+ customerAuth,
+ body('salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
+ body('firstName').optional({ nullable: true }).isString().isLength({ max: 80 }),
+ body('lastName').optional({ nullable: true }).isString().isLength({ max: 80 }),
+ body('displayName').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
+ body('companyName').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('vatId').optional({ nullable: true }).isString().isLength({ max: 40 }),
+ body('addressLine1').optional({ nullable: true }).isString().isLength({ max: 255 }),
+ body('addressLine2').optional({ nullable: true }).isString().isLength({ max: 255 }),
+ body('postalCode').optional({ nullable: true }).isString().isLength({ max: 20 }),
+ body('city').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('state').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('countryCode').optional({ nullable: true }).isString().isLength({ max: 2 }),
+ body('preferredLanguage').optional().isString().isLength({ max: 8 }),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ // Normalise incoming values: trim strings, drop empty → null so the DB
+ // doesn't end up with `' '` rows that look populated but render blank.
+ const updates = {};
+ for (const [camel, snake] of Object.entries(PROFILE_FIELD_MAP)) {
+ if (!Object.prototype.hasOwnProperty.call(req.body, camel)) continue;
+ let value = req.body[camel];
+ if (typeof value === 'string') value = value.trim();
+ if (value === '') value = null;
+ if (snake === 'country_code' && value) {
+ value = String(value).toUpperCase().slice(0, 2);
+ }
+ updates[snake] = value;
+ }
+ updates.updated_at = new Date();
+
+ await db('customer_accounts').where('id', req.customer.id).update(updates);
+
+ const row = await db('customer_accounts').where('id', req.customer.id).first();
+
+ await logActivity('customer_self_profile_update',
+ { customerId: req.customer.id, fields: Object.keys(updates).filter((k) => k !== 'updated_at') },
+ null,
+ { type: 'customer', id: req.customer.id, name: req.customer.email }
+ );
+
+ res.json({ profile: shapeProfile(row) });
+ } catch (error) {
+ logger.error('Customer profile update error:', error);
+ res.status(500).json({ error: 'Failed to update profile' });
+ }
+});
+
+/**
+ * POST /profile/password
+ *
+ * Customer changes their own password. Requires the current password as
+ * proof of identity (so a stolen session cookie can't pivot to a permanent
+ * takeover without also having the old password). Bumps
+ * password_changed_at so any other active sessions for this customer get
+ * invalidated on next request via the customerAuth middleware check.
+ */
+router.post('/profile/password', [
+ customerAuth,
+ body('currentPassword').isString().isLength({ min: 1 }),
+ body('newPassword').isString().isLength({ min: 8 })
+ .withMessage('Password must be at least 8 characters'),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ const { currentPassword, newPassword } = req.body;
+
+ const policyError = validateCustomerPassword(newPassword);
+ if (policyError) {
+ return res.status(400).json({
+ error: 'Password does not meet complexity requirements',
+ details: [policyError],
+ });
+ }
+
+ const row = await db('customer_accounts').where('id', req.customer.id).first();
+ if (!row || !row.password_hash) {
+ return res.status(400).json({ error: 'Password change unavailable' });
+ }
+ const ok = await bcrypt.compare(currentPassword, row.password_hash);
+ if (!ok) {
+ return res.status(401).json({ error: 'Current password is incorrect' });
+ }
+
+ const newHash = await bcrypt.hash(newPassword, getBcryptRounds());
+ await db('customer_accounts').where('id', req.customer.id).update({
+ password_hash: newHash,
+ password_changed_at: new Date(),
+ updated_at: new Date(),
+ });
+
+ await logActivity('customer_password_change',
+ { customerId: req.customer.id },
+ null,
+ { type: 'customer', id: req.customer.id, name: req.customer.email }
+ );
+
+ res.json({ message: 'Password updated' });
+ } catch (error) {
+ logger.error('Customer password change error:', error);
+ res.status(500).json({ error: 'Failed to change password' });
+ }
+});
+
+module.exports = router;
diff --git a/backend/src/routes/customerAuth.js b/backend/src/routes/customerAuth.js
new file mode 100644
index 00000000..5321f427
--- /dev/null
+++ b/backend/src/routes/customerAuth.js
@@ -0,0 +1,369 @@
+/**
+ * Customer-side auth routes
+ *
+ * Mounted at /api/customer/auth (see server.js wiring). Strictly separate
+ * from /api/auth/* (admin) and /api/auth/gallery/* (per-event guests).
+ *
+ * Endpoints:
+ * POST /login email + password → customer_token cookie
+ * POST /logout revoke + clear cookie
+ * GET /session echo current customer for frontend boot
+ * GET /invite/:token public, returns invite metadata
+ * POST /accept-invite public, completes the invitation
+ */
+
+const express = require('express');
+const bcrypt = require('bcrypt');
+const jwt = require('jsonwebtoken');
+const { body, param, validationResult } = require('express-validator');
+const { db, logActivity } = require('../database/db');
+const { formatBoolean } = require('../utils/dbCompat');
+const { verifyRecaptcha } = require('../services/recaptcha');
+const {
+ trackFailedAttempt,
+ trackSuccessfulLogin,
+ checkAccountLockout,
+ getGenericAuthError,
+} = require('../utils/authSecurity');
+const { revokeToken } = require('../utils/tokenRevocation');
+const logger = require('../utils/logger');
+const {
+ setCustomerAuthCookie,
+ clearCustomerAuthCookie,
+ getCustomerTokenFromRequest,
+} = require('../utils/tokenUtils');
+const { getClientIp } = require('../utils/requestIp');
+// NOTE: customers intentionally do NOT go through validatePasswordInContext
+// (the admin-grade policy that can require special chars, dictionary checks,
+// breach lists, etc.). Customers are end-users picking a one-off password —
+// the friction of the admin policy turned them away. We enforce a simple,
+// human-readable rule below: minimum length, at least one uppercase letter,
+// at least one digit. No special-character or breach-list requirement.
+const customerAccountsService = require('../services/customerAccountsService');
+const { customerAuth } = require('../middleware/customerAuth');
+
+const router = express.Router();
+
+const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
+
+// ---- login -------------------------------------------------------------
+
+router.post('/login', [
+ body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
+ body('password').isString().notEmpty(),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+
+ const { email, password, recaptchaToken } = req.body;
+ const ipAddress = getClientIp(req);
+ const userAgent = req.headers['user-agent'] || '';
+
+ // Lockout key includes a `customer:` prefix so admin and customer
+ // attempt counters don't share a bucket — an attacker hitting an
+ // admin login with the same email should not lock out the customer
+ // account or vice versa.
+ const lockoutKey = `customer:${email}`;
+ const lockoutStatus = await checkAccountLockout(lockoutKey);
+ if (lockoutStatus.isLocked) {
+ logger.warn('Customer login attempt on locked account', { email, ipAddress });
+ return res.status(423).json({
+ error: 'Account temporarily locked due to too many failed attempts',
+ retryAfter: lockoutStatus.remainingTime,
+ });
+ }
+
+ const recaptchaValid = await verifyRecaptcha(recaptchaToken);
+ if (!recaptchaValid) {
+ await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
+ return res.status(400).json({ error: 'reCAPTCHA verification failed' });
+ }
+
+ const customer = await db('customer_accounts').where('email', email).first();
+ // Generic error to prevent user enumeration — same wording as admin login.
+ if (!customer || !customer.password_hash || !await bcrypt.compare(password, customer.password_hash)) {
+ await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
+ return res.status(401).json({ error: getGenericAuthError() });
+ }
+ if (!customer.is_active) {
+ await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
+ return res.status(401).json({ error: getGenericAuthError() });
+ }
+
+ await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
+ await db('customer_accounts').where('id', customer.id).update({
+ last_login: new Date(),
+ last_login_ip: ipAddress,
+ });
+
+ const token = jwt.sign({
+ customerId: customer.id,
+ email: customer.email,
+ type: 'customer',
+ ip: ipAddress,
+ loginTime: Date.now(),
+ }, process.env.JWT_SECRET, {
+ expiresIn: TOKEN_TTL_SECONDS,
+ issuer: 'picpeak-auth',
+ });
+
+ setCustomerAuthCookie(res, token);
+
+ await logActivity('customer_login',
+ { customerId: customer.id, email: customer.email, ipAddress },
+ null,
+ { type: 'customer', id: customer.id, name: customer.email }
+ );
+
+ // Resolve effective features + branding right here so the login
+ // response carries the same shape as /session. Without this, the
+ // first-render dashboard after login would use the context's
+ // DEFAULT_FEATURES (all false) — features only "appear" on the next
+ // CustomerAuthProvider mount (e.g. after the user navigates to a
+ // gallery and back). Mirroring the /session resolution keeps the
+ // frontend on a single source of truth.
+ let features = { calendar: false, quotes: false, bills: false };
+ let branding = { showLogo: true, showCompanyName: true };
+ try {
+ features = await customerAccountsService.getEffectiveFeaturesForCustomer(customer);
+ const globals = await customerAccountsService.getCustomerSurfaceGlobals();
+ branding = { showLogo: globals.showLogo, showCompanyName: globals.showCompanyName };
+ } catch (e) {
+ logger.warn('Customer login: failed to resolve features/branding, using defaults', { error: e?.message });
+ }
+
+ res.json({
+ customer: {
+ id: customer.id,
+ email: customer.email,
+ displayName: customer.display_name,
+ firstName: customer.first_name,
+ lastName: customer.last_name,
+ preferredLanguage: customer.preferred_language || 'en',
+ },
+ features,
+ branding,
+ });
+ } catch (error) {
+ logger.error('Customer login error:', error);
+ res.status(500).json({ error: 'Login failed' });
+ }
+});
+
+// ---- logout ------------------------------------------------------------
+
+router.post('/logout', async (req, res) => {
+ try {
+ const token = getCustomerTokenFromRequest(req);
+ if (token) {
+ await revokeToken(token, 'user_logout');
+ }
+ clearCustomerAuthCookie(res);
+ res.json({ message: 'Logged out successfully' });
+ } catch (error) {
+ logger.error('Customer logout error:', error);
+ // Always clear the cookie even if revocation failed — the client must
+ // not stay locked into a half-broken session.
+ clearCustomerAuthCookie(res);
+ res.status(500).json({ error: 'Logout failed' });
+ }
+});
+
+// ---- session echo ------------------------------------------------------
+
+router.get('/session', customerAuth, async (req, res) => {
+ // Resolve the effective feature set (global toggle AND per-customer flag)
+ // and the branding visibility globals so the customer frontend can render
+ // the correct sidebar without an extra round-trip on every navigation.
+ // Failure here is non-fatal — the customer should still be able to see
+ // their galleries even if the settings table is briefly unavailable.
+ let features = { calendar: false, quotes: false, bills: false };
+ let branding = { showLogo: true, showCompanyName: true };
+ try {
+ features = await customerAccountsService.getEffectiveFeaturesForCustomer(req.customer.id);
+ const globals = await customerAccountsService.getCustomerSurfaceGlobals();
+ branding = { showLogo: globals.showLogo, showCompanyName: globals.showCompanyName };
+ } catch (e) {
+ logger.warn('Customer session: failed to resolve features/branding, using defaults', { error: e?.message });
+ }
+ res.json({ customer: req.customer, features, branding });
+});
+
+// ---- invitation lifecycle (public) -------------------------------------
+
+router.get('/invite/:token', [
+ param('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(404).json({ error: 'Invalid invitation link' });
+ }
+ const invitation = await customerAccountsService.validateInvitationToken(req.params.token);
+ if (!invitation) {
+ return res.status(404).json({ error: 'Invalid or expired invitation' });
+ }
+ res.json({
+ invitation: {
+ email: invitation.email,
+ expiresAt: invitation.expires_at,
+ invitedBy: invitation.invited_by_username,
+ // Surface admin-supplied prefill so the accept page can populate
+ // its profile form. Customer can still edit any field — we just
+ // saved them some typing.
+ prefill: invitation.prefill || null,
+ },
+ });
+ } catch (error) {
+ logger.error('Customer invite lookup error:', error);
+ res.status(500).json({ error: 'Failed to load invitation' });
+ }
+});
+
+/**
+ * Customer-specific password policy.
+ *
+ * Intentionally simpler than validatePasswordInContext (the admin-grade
+ * checker). Rules:
+ * - At least 8 characters
+ * - At least one uppercase letter (A–Z)
+ * - At least one digit (0–9)
+ *
+ * No special-character requirement, no breach-list lookup, no dictionary
+ * check — those tripped up real customers picking real passwords (e.g.
+ * "PartyTime2026"). Capitals + a number is enough entropy for an
+ * account that only views galleries; it's not protecting financial data.
+ *
+ * Returns null on success, or a string error message on failure.
+ */
+function validateCustomerPassword(password) {
+ if (typeof password !== 'string' || password.length < 8) {
+ return 'Password must be at least 8 characters long.';
+ }
+ if (!/[A-Z]/.test(password)) {
+ return 'Password must contain at least one uppercase letter.';
+ }
+ if (!/[0-9]/.test(password)) {
+ return 'Password must contain at least one number.';
+ }
+ return null;
+}
+
+router.post('/accept-invite', [
+ body('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
+ body('name').optional({ nullable: true }).isString().trim().isLength({ max: 120 }),
+ // Length floor enforced again here for an early reject; the full
+ // policy (uppercase + digit) is checked below so we can surface a
+ // specific message rather than a generic validator error.
+ body('password').isString().isLength({ min: 8 })
+ .withMessage('Password must be at least 8 characters'),
+ // Optional structured profile from the accept-invite form. Mirrors
+ // the admin prefill shape — anything the customer types here wins
+ // over the admin prefill stashed on the invitation row.
+ body('profile').optional().isObject(),
+ body('profile.salutation').optional({ nullable: true }).isString().isLength({ max: 32 }),
+ body('profile.first_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
+ body('profile.last_name').optional({ nullable: true }).isString().isLength({ max: 80 }),
+ body('profile.display_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('profile.phone').optional({ nullable: true }).isString().isLength({ max: 40 }),
+ body('profile.company_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('profile.vat_id').optional({ nullable: true }).isString().isLength({ max: 40 }),
+ body('profile.address_line1').optional({ nullable: true }).isString().isLength({ max: 255 }),
+ body('profile.address_line2').optional({ nullable: true }).isString().isLength({ max: 255 }),
+ body('profile.postal_code').optional({ nullable: true }).isString().isLength({ max: 20 }),
+ body('profile.city').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('profile.state').optional({ nullable: true }).isString().isLength({ max: 120 }),
+ body('profile.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) {
+ return res.status(400).json({ errors: errors.array() });
+ }
+ const { token, name, password, profile } = req.body;
+
+ const policyError = validateCustomerPassword(password);
+ if (policyError) {
+ return res.status(400).json({
+ error: 'Password does not meet complexity requirements',
+ details: [policyError],
+ });
+ }
+
+ const result = await customerAccountsService.acceptInvitation({ token, name, password, profile });
+ res.json({ message: 'Invitation accepted', email: result.email });
+ } catch (error) {
+ if (error.code === 'CONFLICT' || error.statusCode === 409) {
+ return res.status(409).json({ error: error.message });
+ }
+ if (error.code === 'VALIDATION' || error.statusCode === 400) {
+ return res.status(400).json({ error: error.message });
+ }
+ logger.error('Customer invite accept error:', error);
+ res.status(500).json({ error: 'Failed to accept invitation' });
+ }
+});
+
+// ---- password reset (public) -------------------------------------------
+
+/**
+ * GET /password-reset/:token (#354 follow-up).
+ *
+ * Validate a reset token without consuming it so the reset page can show
+ * "you're resetting the password for {{email}}" before the user submits.
+ */
+router.get('/password-reset/:token', [
+ param('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) return res.status(404).json({ error: 'Invalid reset link' });
+ const reset = await customerAccountsService.validatePasswordResetToken(req.params.token);
+ if (!reset) return res.status(404).json({ error: 'Invalid or expired reset link' });
+ res.json({ reset: { email: reset.email, expiresAt: reset.expires_at } });
+ } catch (error) {
+ logger.error('Customer reset lookup error:', error);
+ res.status(500).json({ error: 'Failed to validate reset link' });
+ }
+});
+
+/**
+ * POST /password-reset (#354 follow-up).
+ *
+ * Apply a reset: token + new password. Same simple password policy as
+ * the accept-invite path (8 chars, uppercase, digit). The service marks
+ * the reset row as used in the same transaction so a re-submitted token
+ * is rejected on the second click.
+ */
+router.post('/password-reset', [
+ body('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
+ body('password').isString().isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
+], async (req, res) => {
+ try {
+ const errors = validationResult(req);
+ if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
+ const policyError = validateCustomerPassword(req.body.password);
+ if (policyError) {
+ return res.status(400).json({
+ error: 'Password does not meet complexity requirements',
+ details: [policyError],
+ });
+ }
+ const result = await customerAccountsService.applyPasswordReset({
+ token: req.body.token,
+ password: req.body.password,
+ });
+ res.json({ message: 'Password updated', email: result.email });
+ } catch (error) {
+ if (error.code === 'VALIDATION' || error.statusCode === 400) {
+ return res.status(400).json({ error: error.message });
+ }
+ logger.error('Customer reset apply error:', error);
+ res.status(500).json({ error: 'Failed to reset password' });
+ }
+});
+
+module.exports = router;
diff --git a/backend/src/services/customerAccountsService.js b/backend/src/services/customerAccountsService.js
new file mode 100644
index 00000000..5004c829
--- /dev/null
+++ b/backend/src/services/customerAccountsService.js
@@ -0,0 +1,957 @@
+/**
+ * Customer Accounts Service
+ *
+ * Recurring user logins (the third user tier alongside admin and guest).
+ * See discussion the-luap/picpeak#354 and migration 087 for context.
+ *
+ * Mirrors userManagementService.js for invitation lifecycle but operates
+ * on customer_accounts / customer_invitations / event_customer_assignments
+ * — separate token type ('customer'), simpler permission model (a customer
+ * either has access to a given event or doesn't, no RBAC).
+ */
+
+const bcrypt = require('bcrypt');
+const crypto = require('crypto');
+const { db, logActivity } = require('../database/db');
+const { formatBoolean } = require('../utils/dbCompat');
+const { getBcryptRounds } = require('../utils/passwordValidation');
+const { queueEmail } = require('./emailProcessor');
+const { getFrontendBaseUrl } = require('../utils/frontendUrl');
+const logger = require('../utils/logger');
+const { ConflictError, NotFoundError, ValidationError } = require('../utils/errors');
+
+const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days, matches admin invites
+
+/**
+ * Whitelist of customer profile fields the admin is allowed to pre-fill on
+ * an invitation (and that the customer can then edit on accept). Centralised
+ * so both invite-create and accept paths agree on what survives the round
+ * trip.
+ */
+const PREFILLABLE_FIELDS = [
+ 'salutation',
+ 'first_name',
+ 'last_name',
+ 'display_name',
+ 'phone',
+ 'company_name',
+ 'vat_id',
+ 'address_line1',
+ 'address_line2',
+ 'postal_code',
+ 'city',
+ 'state',
+ 'country_code',
+];
+
+/**
+ * Sanitise a free-form prefill payload coming from the admin UI. Trims
+ * whitespace, drops anything not in the whitelist, uppercases ISO country
+ * codes, and returns null if the result is effectively empty so we don't
+ * litter the DB with `{}` rows.
+ */
+function sanitisePrefill(input) {
+ if (!input || typeof input !== 'object') return null;
+ const out = {};
+ for (const field of PREFILLABLE_FIELDS) {
+ const raw = input[field];
+ if (raw === undefined || raw === null) continue;
+ const trimmed = String(raw).trim();
+ if (!trimmed) continue;
+ if (field === 'country_code') {
+ out[field] = trimmed.toUpperCase().slice(0, 2);
+ } else {
+ out[field] = trimmed;
+ }
+ }
+ return Object.keys(out).length > 0 ? out : null;
+}
+
+/**
+ * Decode the prefill_data column. Postgres returns it pre-parsed; SQLite /
+ * older drivers may hand back a JSON string. Tolerate both, fail soft.
+ */
+function decodePrefill(raw) {
+ if (!raw) return null;
+ if (typeof raw === 'object') return raw;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Create a new customer invitation.
+ *
+ * Idempotency: rejects if an active customer with this email already
+ * exists, OR if a non-expired pending invitation is already in flight.
+ * The latter is intentional — re-sending an invite while one is open
+ * would mint two valid tokens, doubling the attack surface. Admins must
+ * cancel the open invitation first if they want to re-send.
+ *
+ * @returns {Promise<{ id, email, token, expiresAt }>}
+ */
+async function createInvitation({ email, invitedById, prefill }) {
+ const normalisedEmail = String(email || '').trim().toLowerCase();
+ if (!normalisedEmail) {
+ throw new ValidationError('Email is required');
+ }
+
+ const existingCustomer = await db('customer_accounts')
+ .where('email', normalisedEmail)
+ .first();
+ if (existingCustomer) {
+ throw new ConflictError('A customer account with this email already exists', 'email');
+ }
+
+ const pendingInvite = await db('customer_invitations')
+ .where('email', normalisedEmail)
+ .whereNull('accepted_at')
+ .where('expires_at', '>', new Date())
+ .first();
+ if (pendingInvite) {
+ throw new ConflictError('A pending invitation already exists for this email', 'email');
+ }
+
+ // 64-char hex = 32 bytes = 256 bits of entropy. Same as admin invites.
+ const token = crypto.randomBytes(32).toString('hex');
+ const expiresAt = new Date(Date.now() + INVITATION_TTL_MS);
+ const sanitisedPrefill = sanitisePrefill(prefill);
+
+ const [insertedId] = await db('customer_invitations').insert({
+ email: normalisedEmail,
+ token,
+ invited_by: invitedById,
+ expires_at: expiresAt,
+ created_at: new Date(),
+ // Stringify so SQLite (TEXT-typed json column) and Postgres (JSONB)
+ // both store the same shape. Skip the column entirely on null so
+ // databases predating migration 088 don't reject the insert.
+ ...(sanitisedPrefill ? { prefill_data: JSON.stringify(sanitisedPrefill) } : {}),
+ }).returning('id');
+ const id = insertedId?.id || insertedId;
+
+ // Queue invitation email. The customer-facing accept page lives at
+ // /customer/invite/:token (see CustomerAcceptInvitePage.tsx).
+ //
+ // Resolve the frontend base URL through the shared helper so the link
+ // honours Site Settings → Site URL (general_site_url) rather than the
+ // local FRONTEND_URL env var. The helper still falls back to
+ // FRONTEND_URL → 'http://localhost:3000' if the setting isn't set, but
+ // a configured deployment will always use its real domain. (Admin
+ // invites in userManagementService use the env-only fallback — that's
+ // a separate bug to be fixed alongside this PR or after.)
+ const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
+ await queueEmail(null, normalisedEmail, 'customer_invitation', {
+ invite_link: `${frontendUrl}/customer/invite/${token}`,
+ expires_at: expiresAt.toISOString(),
+ });
+
+ await logActivity('customer_invitation_created',
+ { email: normalisedEmail },
+ null,
+ { type: 'admin', id: invitedById, name: 'system' }
+ );
+
+ logger.info('Customer invitation created', { email: normalisedEmail, invitedById });
+ return { id, email: normalisedEmail, token, expiresAt };
+}
+
+/**
+ * Accept an invitation. Creates the customer_accounts row in a transaction
+ * and marks the invitation accepted, so a partial failure can't leave a
+ * dangling account or a re-usable token.
+ */
+async function acceptInvitation({ token, name, password, profile }) {
+ const invitation = await db('customer_invitations')
+ .where('token', token)
+ .whereNull('accepted_at')
+ .where('expires_at', '>', new Date())
+ .first();
+
+ if (!invitation) {
+ throw new ValidationError('Invalid or expired invitation');
+ }
+
+ // Race-condition guard: an admin may have created the customer manually
+ // (future flow) between the invite link being generated and clicked.
+ const existing = await db('customer_accounts')
+ .where('email', invitation.email)
+ .first();
+ if (existing) {
+ throw new ConflictError('Email already registered', 'email');
+ }
+
+ const passwordHash = await bcrypt.hash(password, getBcryptRounds());
+
+ // Merge the admin's prefill with whatever the customer typed on the accept
+ // form. Customer-supplied values win on every key — the accept page shows
+ // the prefill values pre-populated but the customer is allowed to correct
+ // anything (e.g. an admin's typo in the company name).
+ const adminPrefill = decodePrefill(invitation.prefill_data) || {};
+ const customerProfile = sanitisePrefill(profile) || {};
+ const merged = { ...adminPrefill, ...customerProfile };
+ // The legacy single-name field still wins over a separately-typed
+ // display_name only if the merged record didn't carry one. Keeps backwards
+ // compatibility with clients that haven't been updated to send the
+ // structured profile.
+ if (name && !merged.display_name) {
+ merged.display_name = String(name).trim();
+ }
+
+ const customerId = await db.transaction(async (trx) => {
+ const [inserted] = await trx('customer_accounts').insert({
+ email: invitation.email,
+ // Profile fields land directly on the customer row. Anything the user
+ // didn't set stays null.
+ salutation: merged.salutation || null,
+ first_name: merged.first_name || null,
+ last_name: merged.last_name || null,
+ display_name: merged.display_name || null,
+ phone: merged.phone || null,
+ company_name: merged.company_name || null,
+ vat_id: merged.vat_id || null,
+ address_line1: merged.address_line1 || null,
+ address_line2: merged.address_line2 || null,
+ postal_code: merged.postal_code || null,
+ city: merged.city || null,
+ state: merged.state || null,
+ country_code: merged.country_code || null,
+ password_hash: passwordHash,
+ is_active: formatBoolean(true),
+ must_change_password: formatBoolean(false),
+ // Leave password_changed_at NULL on initial accept. Setting it here
+ // creates a millisecond/second-rounding race with the JWT issued
+ // by the immediate /login call: stored timestamp X.500ms can floor
+ // to X+1 in postgres while the JWT's iat lands at X, causing the
+ // customerAuth middleware's `iat < password_changed_at` check to
+ // reject perfectly valid tokens on the very next page reload. We
+ // populate password_changed_at only when an actual password change
+ // happens later (deactivate / reset flows).
+ password_changed_at: null,
+ created_by_admin_id: invitation.invited_by,
+ created_at: new Date(),
+ updated_at: new Date(),
+ }).returning('id');
+ const id = inserted?.id || inserted;
+
+ await trx('customer_invitations')
+ .where('id', invitation.id)
+ .update({ accepted_at: new Date(), accepted_customer_id: id });
+
+ return id;
+ });
+
+ await logActivity('customer_invitation_accepted',
+ { customerId, email: invitation.email, invitationId: invitation.id },
+ null,
+ { type: 'system', id: null, name: 'system' }
+ );
+
+ logger.info('Customer invitation accepted', { customerId, email: invitation.email });
+ return { customerId, email: invitation.email };
+}
+
+/**
+ * Look up an invitation token without consuming it. Used by the accept
+ * page so it can render the email + expiry before the user submits.
+ */
+async function validateInvitationToken(token) {
+ const invitation = await db('customer_invitations')
+ .leftJoin('admin_users', 'admin_users.id', 'customer_invitations.invited_by')
+ .where('customer_invitations.token', token)
+ .whereNull('customer_invitations.accepted_at')
+ .where('customer_invitations.expires_at', '>', new Date())
+ .select(
+ 'customer_invitations.email',
+ 'customer_invitations.expires_at',
+ 'customer_invitations.prefill_data',
+ 'admin_users.username as invited_by_username'
+ )
+ .first();
+ if (!invitation) return null;
+ return {
+ ...invitation,
+ prefill: decodePrefill(invitation.prefill_data),
+ };
+}
+
+/**
+ * Customer roster for the admin Customers page. Includes a count of how
+ * many events each customer has access to, so the admin can spot orphaned
+ * accounts at a glance.
+ */
+async function listCustomers({ search } = {}) {
+ let q = db('customer_accounts')
+ .leftJoin('event_customer_assignments', 'event_customer_assignments.customer_account_id', 'customer_accounts.id')
+ .groupBy('customer_accounts.id')
+ .select(
+ 'customer_accounts.id',
+ 'customer_accounts.email',
+ 'customer_accounts.display_name',
+ 'customer_accounts.first_name',
+ 'customer_accounts.last_name',
+ 'customer_accounts.salutation',
+ 'customer_accounts.company_name',
+ 'customer_accounts.is_active',
+ 'customer_accounts.last_login',
+ 'customer_accounts.created_at',
+ db.raw('COUNT(event_customer_assignments.id) as event_count')
+ )
+ .orderBy('customer_accounts.created_at', 'desc');
+
+ if (search && String(search).trim()) {
+ const term = `%${String(search).trim().toLowerCase()}%`;
+ q = q.where(function () {
+ this.whereRaw('LOWER(customer_accounts.email) LIKE ?', [term])
+ .orWhereRaw('LOWER(COALESCE(customer_accounts.display_name, \'\')) LIKE ?', [term])
+ .orWhereRaw('LOWER(COALESCE(customer_accounts.last_name, \'\')) LIKE ?', [term])
+ .orWhereRaw('LOWER(COALESCE(customer_accounts.company_name, \'\')) LIKE ?', [term]);
+ });
+ }
+
+ return q;
+}
+
+/**
+ * Single customer record + their event assignments. Used by the admin
+ * detail view; the customer's own dashboard uses listEventsForCustomer.
+ */
+async function getCustomerById(id) {
+ const customer = await db('customer_accounts').where('id', id).first();
+ if (!customer) {
+ throw new NotFoundError('Customer', id);
+ }
+ const events = await db('event_customer_assignments')
+ .join('events', 'events.id', 'event_customer_assignments.event_id')
+ .where('event_customer_assignments.customer_account_id', id)
+ .select(
+ 'events.id',
+ 'events.slug',
+ 'events.event_name',
+ 'events.event_date',
+ 'events.expires_at',
+ 'events.is_archived',
+ 'event_customer_assignments.assigned_at'
+ )
+ .orderBy('event_customer_assignments.assigned_at', 'desc');
+ return { ...customer, events };
+}
+
+/**
+ * Update customer profile. Admins can edit any field except auth-related
+ * columns (password_hash, password_changed_at, must_change_password) which
+ * are mutated by deactivate / reset / accept paths only.
+ *
+ * email changes deliberately allowed — the admin may need to correct a
+ * typo before the customer accepts. Uniqueness is enforced.
+ */
+async function updateCustomer(id, updates, updatedByAdminId) {
+ const customer = await db('customer_accounts').where('id', id).first();
+ if (!customer) {
+ throw new NotFoundError('Customer', id);
+ }
+
+ const allowed = {};
+ const fields = [
+ 'email', 'salutation', 'first_name', 'last_name', 'display_name',
+ 'phone', 'company_name', 'billing_email', 'vat_id',
+ 'address_line1', 'address_line2', 'postal_code', 'city', 'state',
+ 'country_code', 'preferred_language', 'notes',
+ // Per-customer feature flags (#354 follow-up). Booleans below are
+ // coerced via formatBoolean for SQLite compatibility.
+ 'feature_calendar', 'feature_quotes', 'feature_bills',
+ ];
+ for (const f of fields) {
+ if (updates[f] !== undefined) {
+ // Trim+lowercase email; everything else passes through. country_code
+ // is uppercased to match ISO 3166-1 alpha-2 convention.
+ if (f === 'email') {
+ allowed[f] = String(updates[f] || '').trim().toLowerCase();
+ } else if (f === 'country_code' && updates[f]) {
+ allowed[f] = String(updates[f]).trim().toUpperCase().slice(0, 2);
+ } else if (f === 'feature_calendar' || f === 'feature_quotes' || f === 'feature_bills') {
+ allowed[f] = formatBoolean(updates[f]);
+ } else {
+ allowed[f] = updates[f];
+ }
+ }
+ }
+
+ if (allowed.email && allowed.email !== customer.email) {
+ const conflict = await db('customer_accounts')
+ .where('email', allowed.email)
+ .whereNot('id', id)
+ .first();
+ if (conflict) {
+ throw new ConflictError('Email already in use', 'email');
+ }
+ }
+
+ if (updates.is_active !== undefined) {
+ allowed.is_active = formatBoolean(updates.is_active);
+ }
+
+ allowed.updated_at = new Date();
+ await db('customer_accounts').where('id', id).update(allowed);
+
+ await logActivity('customer_updated',
+ { customerId: id, fields: Object.keys(allowed) },
+ null,
+ { type: 'admin', id: updatedByAdminId, name: 'system' }
+ );
+
+ return getCustomerById(id);
+}
+
+/**
+ * Soft-delete: is_active=false. Existing JWTs become invalid because the
+ * customerAuth middleware re-checks is_active on every request. Junction
+ * rows are kept for audit (event history shows who had access historically).
+ */
+async function deactivateCustomer(id, deactivatedByAdminId) {
+ const customer = await db('customer_accounts').where('id', id).first();
+ if (!customer) {
+ throw new NotFoundError('Customer', id);
+ }
+
+ await db('customer_accounts').where('id', id).update({
+ is_active: formatBoolean(false),
+ // Bumping password_changed_at invalidates any outstanding tokens
+ // immediately — same trick adminAuth uses.
+ password_changed_at: new Date(),
+ updated_at: new Date(),
+ });
+
+ await logActivity('customer_deactivated',
+ { customerId: id, email: customer.email },
+ null,
+ { type: 'admin', id: deactivatedByAdminId, name: 'system' }
+ );
+
+ logger.info('Customer deactivated', { customerId: id, deactivatedByAdminId });
+}
+
+/**
+ * Reactivate a previously-deactivated customer. Restores login (is_active
+ * back to true), but does NOT re-grant any historical assignments — those
+ * were never removed (deactivate keeps the junction rows for audit), so
+ * the customer immediately sees the same galleries they had before.
+ */
+async function reactivateCustomer(id, reactivatedByAdminId) {
+ const customer = await db('customer_accounts').where('id', id).first();
+ if (!customer) {
+ throw new NotFoundError('Customer', id);
+ }
+ if (customer.is_active) {
+ return; // already active, no-op
+ }
+
+ await db('customer_accounts').where('id', id).update({
+ is_active: formatBoolean(true),
+ // Don't touch password_changed_at — the customer's password (if set)
+ // remains valid. They log in with their existing credential.
+ updated_at: new Date(),
+ });
+
+ await logActivity('customer_reactivated',
+ { customerId: id, email: customer.email },
+ null,
+ { type: 'admin', id: reactivatedByAdminId, name: 'system' }
+ );
+
+ logger.info('Customer reactivated', { customerId: id, reactivatedByAdminId });
+}
+
+/**
+ * GDPR-style erasure: anonymize-in-place rather than hard delete.
+ *
+ * Why anonymize, not delete?
+ * - `customer_invitations.accepted_customer_id` has no ON DELETE CASCADE
+ * (Postgres default RESTRICT), so a hard delete would fail if the
+ * customer accepted any invitations. Anonymize sidesteps the FK
+ * constraint entirely.
+ * - `event_customer_assignments` and `activity_logs` rows referencing
+ * this customer are part of the gallery's access audit trail. Wiping
+ * them would leave gaps that "who had access to this event" queries
+ * can't recover from.
+ *
+ * What we do:
+ * - Replace the email with a sentinel `deleted--@deleted.invalid`
+ * so unique-on-email holds and the address can never collide with a
+ * real customer the admin invites later.
+ * - NULL every PII column (name, phone, company, vat, full address, notes).
+ * - Wipe `password_hash` so credentials can't be reused.
+ * - Set `is_active=false` and bump `password_changed_at` so any
+ * outstanding tokens die immediately.
+ * - Delete pending invitations + reset tokens for this customer.
+ *
+ * What we keep:
+ * - The customer_accounts row itself (anonymized).
+ * - Their event_customer_assignments rows (with a now-anonymized FK).
+ * - All activity_logs / access_logs (audit trail).
+ *
+ * Wrapped in a transaction so a partial failure doesn't leave half-erased
+ * state.
+ */
+async function eraseCustomer(id, erasedByAdminId) {
+ const customer = await db('customer_accounts').where('id', id).first();
+ if (!customer) {
+ throw new NotFoundError('Customer', id);
+ }
+
+ // Sentinel email — uses the .invalid TLD (RFC 6761) so it can't be
+ // a valid deliverable address, even by accident. Includes the id +
+ // a random suffix so a re-erase of a different account doesn't
+ // collide on the unique index.
+ const sentinelEmail = `deleted-${id}-${crypto.randomBytes(4).toString('hex')}@deleted.invalid`;
+
+ await db.transaction(async (trx) => {
+ await trx('customer_accounts').where('id', id).update({
+ email: sentinelEmail,
+ salutation: null,
+ first_name: null,
+ last_name: null,
+ display_name: null,
+ phone: null,
+ company_name: null,
+ billing_email: null,
+ vat_id: null,
+ address_line1: null,
+ address_line2: null,
+ postal_code: null,
+ city: null,
+ state: null,
+ country_code: null,
+ notes: null,
+ password_hash: null,
+ is_active: formatBoolean(false),
+ must_change_password: formatBoolean(false),
+ password_changed_at: new Date(),
+ updated_at: new Date(),
+ });
+
+ // Drop pending invitations the customer hasn't accepted yet AND any
+ // that ARE pointed at this customer (accepted_customer_id) — keep the
+ // history columns (email + invited_by + accepted_at) on the
+ // already-accepted ones since those reference the now-sentinel email
+ // and serve as audit. We just clear the FK pointer.
+ await trx('customer_invitations').where('email', customer.email).whereNull('accepted_at').del();
+ await trx('customer_invitations').where('accepted_customer_id', id).update({
+ // Keep the row, drop the back-pointer so a future hard-delete (if
+ // ever added) doesn't FK-block.
+ accepted_customer_id: null,
+ });
+
+ // Active reset tokens for this customer should be invalidated.
+ await trx('customer_password_resets').where('customer_account_id', id).del();
+ });
+
+ await logActivity('customer_erased',
+ { customerId: id, originalEmail: customer.email },
+ null,
+ { type: 'admin', id: erasedByAdminId, name: 'system' }
+ );
+
+ logger.info('Customer erased (anonymized in place)', { customerId: id, erasedByAdminId });
+}
+
+/**
+ * Autocomplete for the event-form picker. Returns up to `limit` rows
+ * matching the email/name prefix. Active customers only — deactivated
+ * accounts shouldn't show up as assignable options.
+ */
+async function searchCustomers(query, { limit = 10 } = {}) {
+ const term = `%${String(query || '').trim().toLowerCase()}%`;
+ if (!term || term === '%%') return [];
+ return db('customer_accounts')
+ .where('is_active', formatBoolean(true))
+ .andWhere(function () {
+ this.whereRaw('LOWER(email) LIKE ?', [term])
+ .orWhereRaw('LOWER(COALESCE(display_name, \'\')) LIKE ?', [term])
+ .orWhereRaw('LOWER(COALESCE(last_name, \'\')) LIKE ?', [term])
+ .orWhereRaw('LOWER(COALESCE(company_name, \'\')) LIKE ?', [term]);
+ })
+ .select('id', 'email', 'display_name', 'first_name', 'last_name', 'company_name')
+ .orderBy('email', 'asc')
+ .limit(limit);
+}
+
+// ---- assignments ---------------------------------------------------------
+
+/**
+ * Replace the entire assignment set for one event. Used by the admin
+ * event create/update endpoints when they receive `customer_account_ids`
+ * — diff-and-apply inside one transaction so the event row and its
+ * assignments either both update or neither does.
+ *
+ * `targetCustomerIds` may be empty to clear all assignments.
+ */
+async function setAssignmentsForEvent(eventId, targetCustomerIds, adminId, trx = db) {
+ const wanted = new Set((targetCustomerIds || []).map(Number).filter((n) => Number.isFinite(n) && n > 0));
+ const existing = await trx('event_customer_assignments')
+ .where('event_id', eventId)
+ .select('id', 'customer_account_id');
+ const existingIds = new Set(existing.map((r) => r.customer_account_id));
+
+ const toAdd = [...wanted].filter((id) => !existingIds.has(id));
+ const toRemove = existing.filter((r) => !wanted.has(r.customer_account_id));
+
+ if (toRemove.length > 0) {
+ await trx('event_customer_assignments')
+ .whereIn('id', toRemove.map((r) => r.id))
+ .del();
+ }
+
+ if (toAdd.length > 0) {
+ // Validate the customers exist + are active before inserting. Cheaper
+ // than catching FK errors and gives the admin a clear error message.
+ const valid = await trx('customer_accounts')
+ .whereIn('id', toAdd)
+ .where('is_active', formatBoolean(true))
+ .pluck('id');
+ const validSet = new Set(valid);
+ const ignored = toAdd.filter((id) => !validSet.has(id));
+ if (ignored.length > 0) {
+ logger.warn('Ignoring inactive/missing customer ids in assignment', {
+ eventId, ignored,
+ });
+ }
+ const rows = [...validSet].map((customerId) => ({
+ event_id: eventId,
+ customer_account_id: customerId,
+ assigned_by_admin_id: adminId,
+ assigned_at: new Date(),
+ }));
+ if (rows.length > 0) {
+ await trx('event_customer_assignments').insert(rows);
+ }
+ }
+
+ return { added: toAdd.length, removed: toRemove.length };
+}
+
+/**
+ * Fetch the customers currently assigned to an event. Returned by the
+ * admin event-detail endpoint so the picker can hydrate.
+ */
+async function getAssignmentsForEvent(eventId) {
+ return db('event_customer_assignments')
+ .join('customer_accounts', 'customer_accounts.id', 'event_customer_assignments.customer_account_id')
+ .where('event_customer_assignments.event_id', eventId)
+ .select(
+ 'customer_accounts.id',
+ 'customer_accounts.email',
+ 'customer_accounts.display_name',
+ 'customer_accounts.first_name',
+ 'customer_accounts.last_name',
+ 'customer_accounts.is_active'
+ )
+ .orderBy('customer_accounts.email', 'asc');
+}
+
+/**
+ * Events visible to a logged-in customer. Filters out archived events
+ * since those galleries are no longer browsable. Expired events are
+ * deliberately included so customers can see "your gallery has expired"
+ * messaging in the dashboard rather than just disappearing silently.
+ *
+ * is_draft filter intentionally NOT applied: a customer assigned to a
+ * draft gallery should still see it on their dashboard (the photographer
+ * may want them to preview before publish). is_archived stays as the
+ * single hard-exclude — those galleries are gone.
+ *
+ * is_archived has a NOT NULL DEFAULT false from migration 029, so a
+ * plain typed filter is safe — no need for a COALESCE-via-whereRaw
+ * dance (which itself caused a 500 on postgres because the parameter
+ * placeholders weren't accepting the boolean cleanly).
+ */
+async function listEventsForCustomer(customerId) {
+ return db('event_customer_assignments')
+ .join('events', 'events.id', 'event_customer_assignments.event_id')
+ .where('event_customer_assignments.customer_account_id', customerId)
+ .where('events.is_archived', formatBoolean(false))
+ .select(
+ 'events.id',
+ 'events.slug',
+ 'events.event_name',
+ 'events.event_type',
+ 'events.event_date',
+ 'events.expires_at',
+ 'events.is_active',
+ 'event_customer_assignments.assigned_at'
+ )
+ .orderBy('events.event_date', 'desc');
+}
+
+/**
+ * True iff this customer is assigned to this event. Used by the
+ * access-token exchange endpoint in customer.js to decide whether to
+ * mint a gallery token.
+ */
+async function customerHasAccessToEvent(customerId, eventId) {
+ const row = await db('event_customer_assignments')
+ .where('customer_account_id', customerId)
+ .where('event_id', eventId)
+ .first('id');
+ return !!row;
+}
+
+// ---- pending invitations -----------------------------------------------
+
+async function getPendingInvitations() {
+ return db('customer_invitations')
+ .leftJoin('admin_users', 'admin_users.id', 'customer_invitations.invited_by')
+ .whereNull('customer_invitations.accepted_at')
+ .where('customer_invitations.expires_at', '>', new Date())
+ .select(
+ 'customer_invitations.id',
+ 'customer_invitations.email',
+ 'customer_invitations.expires_at',
+ 'customer_invitations.created_at',
+ 'admin_users.username as invited_by'
+ )
+ .orderBy('customer_invitations.created_at', 'desc');
+}
+
+async function cancelInvitation(id, cancelledByAdminId) {
+ const invitation = await db('customer_invitations').where('id', id).first();
+ if (!invitation) {
+ throw new NotFoundError('Invitation', id);
+ }
+ await db('customer_invitations').where('id', id).del();
+
+ await logActivity('customer_invitation_cancelled',
+ { invitationId: id, email: invitation.email },
+ null,
+ { type: 'admin', id: cancelledByAdminId, name: 'system' }
+ );
+ logger.info('Customer invitation cancelled', { invitationId: id, cancelledByAdminId });
+}
+
+// =====================================================================
+// Customer-surface global toggles + password resets (#354 follow-up)
+// =====================================================================
+
+const PASSWORD_RESET_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
+
+/**
+/**
+ * Read the master "Customer portal" feature flag (#354). When false,
+ * every customer-side surface (login, dashboard, accept-invite, reset)
+ * returns 403/410 and the admin "Customers" sidebar entry is hidden.
+ *
+ * Reads from the maintainer's `feature_flags` table (migration 088).
+ * Defaults to false on installs missing the row (e.g. migration 095
+ * hasn't run yet).
+ */
+async function isCustomerPortalEnabled() {
+ try {
+ if (!(await db.schema.hasTable('feature_flags'))) return false;
+ const row = await db('feature_flags').where({ key: 'customerPortal' }).first();
+ if (!row) return false;
+ const v = row.value;
+ return v === true || v === 1 || v === '1' || v === 'true';
+ } catch (e) {
+ // Defensive: if feature_flags is briefly unavailable (early bootstrap,
+ // failover) treat as off rather than throwing a 500 from the gate.
+ return false;
+ }
+}
+
+/**
+ * Customer-surface global feature toggles. The customer-portal feature
+ * flag (read above) is the master switch; calendar / quotes / bills are
+ * locked behind their own maintainer-side flags (Settings → Features)
+ * but those surfaces aren't yet built — return false so the customer
+ * dashboard doesn't render placeholder tabs.
+ *
+ * Branding visibility (logo / company name) is no longer per-instance
+ * configurable on the customer surface — the customer layout always
+ * shows the configured brand to keep parity with /admin.
+ */
+async function getCustomerSurfaceGlobals() {
+ return {
+ calendarEnabled: false,
+ quotesEnabled: false,
+ billsEnabled: false,
+ showLogo: true,
+ showCompanyName: true,
+ };
+}
+
+/**
+ * Compute the effective feature-flag set for a single customer.
+ *
+ * AND-logic: a customer sees a feature iff the global toggle is on AND
+ * their per-customer flag is on. This gives the admin two independent
+ * levers — flip a feature on for the whole instance, then choose which
+ * customers actually see it.
+ *
+ * Pass either a numeric customerId (we'll fetch) or a row already loaded.
+ */
+async function getEffectiveFeaturesForCustomer(customerOrId) {
+ const customer = (typeof customerOrId === 'number')
+ ? await db('customer_accounts').where('id', customerOrId).first()
+ : customerOrId;
+ if (!customer) {
+ return { calendar: false, quotes: false, bills: false };
+ }
+ const globals = await getCustomerSurfaceGlobals();
+ return {
+ calendar: globals.calendarEnabled && customer.feature_calendar === true,
+ quotes: globals.quotesEnabled && customer.feature_quotes === true,
+ bills: globals.billsEnabled && customer.feature_bills === true,
+ };
+}
+
+/**
+ * Admin triggers a password reset for an existing customer.
+ *
+ * Behaviour:
+ * - Generates a 64-char hex token (matches invitation tokens).
+ * - Stores it in customer_password_resets with a 7-day expiry.
+ * - Queues a customer_password_reset email with the link.
+ * - Does NOT invalidate the existing password yet — we only flip
+ * password_changed_at on the actual reset (so a typo'd reset doesn't
+ * lock a customer out of an already-working session).
+ *
+ * Idempotency: if there's an unused, non-expired reset already in flight
+ * we delete it before creating the new one — admins re-clicking the
+ * "Send reset" button shouldn't fan out two valid links.
+ */
+async function createPasswordReset({ customerId, requestedByAdminId }) {
+ const customer = await db('customer_accounts').where('id', customerId).first();
+ if (!customer) {
+ throw new NotFoundError('Customer', customerId);
+ }
+ if (!customer.is_active) {
+ throw new ValidationError('Cannot reset password for an inactive customer');
+ }
+
+ // Clean up any previous unused reset for this customer.
+ await db('customer_password_resets')
+ .where('customer_account_id', customerId)
+ .whereNull('used_at')
+ .del();
+
+ const token = crypto.randomBytes(32).toString('hex');
+ const expiresAt = new Date(Date.now() + PASSWORD_RESET_TTL_MS);
+
+ await db('customer_password_resets').insert({
+ token,
+ customer_account_id: customerId,
+ requested_by_admin_id: requestedByAdminId || null,
+ expires_at: expiresAt,
+ created_at: new Date(),
+ });
+
+ const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
+ await queueEmail(null, customer.email, 'customer_password_reset', {
+ reset_link: `${frontendUrl}/customer/reset-password/${token}`,
+ expires_at: expiresAt.toISOString(),
+ });
+
+ await logActivity('customer_password_reset_requested',
+ { customerId, email: customer.email },
+ null,
+ { type: 'admin', id: requestedByAdminId, name: 'system' }
+ );
+
+ logger.info('Customer password reset requested', { customerId, requestedByAdminId });
+ return { customerId, email: customer.email, expiresAt };
+}
+
+/**
+ * Validate a password-reset token (lookup-only — does NOT consume it).
+ * Used by the customer's reset page to render "you're resetting the
+ * password for X" before they submit.
+ */
+async function validatePasswordResetToken(token) {
+ const row = await db('customer_password_resets')
+ .join('customer_accounts', 'customer_accounts.id', 'customer_password_resets.customer_account_id')
+ .where('customer_password_resets.token', token)
+ .whereNull('customer_password_resets.used_at')
+ .where('customer_password_resets.expires_at', '>', new Date())
+ .where('customer_accounts.is_active', formatBoolean(true))
+ .select(
+ 'customer_password_resets.id',
+ 'customer_password_resets.customer_account_id',
+ 'customer_password_resets.expires_at',
+ 'customer_accounts.email',
+ )
+ .first();
+ return row || null;
+}
+
+/**
+ * Apply a password reset: hash the new password, write it onto the
+ * customer row, mark the reset row used, and bump password_changed_at
+ * so any tokens issued before the reset (e.g. an attacker's session)
+ * stop working on next request.
+ *
+ * Wrapped in a transaction so we can't end up with a used token but no
+ * password update, or vice versa.
+ */
+async function applyPasswordReset({ token, password }) {
+ const row = await db('customer_password_resets')
+ .where('token', token)
+ .whereNull('used_at')
+ .where('expires_at', '>', new Date())
+ .first();
+ if (!row) {
+ throw new ValidationError('Invalid or expired reset link');
+ }
+
+ const customer = await db('customer_accounts').where('id', row.customer_account_id).first();
+ if (!customer || !customer.is_active) {
+ throw new ValidationError('Invalid or expired reset link');
+ }
+
+ const passwordHash = await bcrypt.hash(password, getBcryptRounds());
+ await db.transaction(async (trx) => {
+ await trx('customer_accounts').where('id', customer.id).update({
+ password_hash: passwordHash,
+ password_changed_at: new Date(),
+ must_change_password: formatBoolean(false),
+ updated_at: new Date(),
+ });
+ await trx('customer_password_resets').where('id', row.id).update({ used_at: new Date() });
+ });
+
+ await logActivity('customer_password_reset_applied',
+ { customerId: customer.id, email: customer.email },
+ null,
+ { type: 'system', id: null, name: 'system' }
+ );
+
+ logger.info('Customer password reset applied', { customerId: customer.id });
+ return { email: customer.email };
+}
+
+module.exports = {
+ createInvitation,
+ acceptInvitation,
+ validateInvitationToken,
+ listCustomers,
+ getCustomerById,
+ updateCustomer,
+ deactivateCustomer,
+ reactivateCustomer,
+ eraseCustomer,
+ searchCustomers,
+ setAssignmentsForEvent,
+ getAssignmentsForEvent,
+ listEventsForCustomer,
+ customerHasAccessToEvent,
+ getPendingInvitations,
+ cancelInvitation,
+ // #354 follow-up
+ isCustomerPortalEnabled,
+ getCustomerSurfaceGlobals,
+ getEffectiveFeaturesForCustomer,
+ createPasswordReset,
+ validatePasswordResetToken,
+ applyPasswordReset,
+};
diff --git a/backend/src/utils/tokenRevocation.js b/backend/src/utils/tokenRevocation.js
index 258e3dee..b209ece9 100644
--- a/backend/src/utils/tokenRevocation.js
+++ b/backend/src/utils/tokenRevocation.js
@@ -12,6 +12,20 @@ const logger = require('./logger');
* @param {string} reason - Reason for revocation
* @param {Object} metadata - Additional metadata
*/
+/**
+ * Resolve the per-token unique identifier used as the lookup key in
+ * revoked_tokens.token_id. Customer JWTs (#354) use `customerId` instead
+ * of `id`, so the original `${payload.id}-${payload.iat}` produced
+ * `undefined-…` keys for every customer token and silently collided
+ * across all customer logins. Falling back to customerId — and finally
+ * to a stable hash of the payload — keeps the key unique per token.
+ */
+function buildTokenId(payload) {
+ if (payload.jti) return payload.jti;
+ const subject = payload.id ?? payload.customerId ?? payload.guestId ?? payload.eventId ?? 'anon';
+ return `${subject}-${payload.iat}-${payload.type || 'unknown'}`;
+}
+
async function revokeToken(token, reason, metadata = {}) {
try {
// Extract token info without full verification (it might be compromised)
@@ -19,26 +33,37 @@ async function revokeToken(token, reason, metadata = {}) {
if (parts.length !== 3) {
throw new Error('Invalid token format');
}
-
+
// Decode payload
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
-
+
+ // user_id is integer-typed in revoked_tokens; for non-admin tokens
+ // we may not have an integer (customer) or any id at all (gallery
+ // tokens use eventId). Coerce to null instead of letting an
+ // undefined/string slip through and cause an INSERT type error.
+ const userIdNumeric = Number.isInteger(payload.id) ? payload.id : null;
+
+ // onConflict.ignore: revoking an already-revoked token is a no-op,
+ // not an error. Hits the unique (token_id) index when the same JWT
+ // is logged out twice (e.g. duplicate /logout from two tabs, or a
+ // session-expiry path that races with an explicit logout). The
+ // previous insert was authoritative; nothing to do.
await db('revoked_tokens').insert({
- token_id: payload.jti || `${payload.id}-${payload.iat}`, // JWT ID or fallback
- user_id: payload.id,
+ token_id: buildTokenId(payload),
+ user_id: userIdNumeric,
token_type: payload.type,
revoked_at: new Date().toISOString(),
expires_at: new Date(payload.exp * 1000).toISOString(),
reason,
metadata: JSON.stringify(metadata)
- });
-
+ }).onConflict('token_id').ignore();
+
logger.info('Token revoked', {
- userId: payload.id,
+ userId: payload.id ?? payload.customerId ?? null,
tokenType: payload.type,
reason
});
-
+
return true;
} catch (error) {
logger.error('Failed to revoke token', error);
@@ -53,7 +78,7 @@ async function revokeToken(token, reason, metadata = {}) {
*/
async function isTokenRevoked(decodedToken) {
try {
- const tokenId = decodedToken.jti || `${decodedToken.id}-${decodedToken.iat}`;
+ const tokenId = buildTokenId(decodedToken);
const revoked = await db('revoked_tokens')
.where('token_id', tokenId)
diff --git a/backend/src/utils/tokenUtils.js b/backend/src/utils/tokenUtils.js
index 4d30760b..cc0f8286 100644
--- a/backend/src/utils/tokenUtils.js
+++ b/backend/src/utils/tokenUtils.js
@@ -2,29 +2,25 @@ const ADMIN_COOKIE_NAME = 'admin_token';
const GALLERY_COOKIE_NAME = 'gallery_token';
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
const GUEST_COOKIE_PREFIX = 'guest_token_';
+// Customer-account session cookie (#354). Distinct name + path from the
+// admin cookie so a single browser can hold both an admin and a customer
+// session without one clobbering the other (e.g. for the admin dogfooding
+// the customer dashboard).
+const CUSTOMER_COOKIE_NAME = 'customer_token';
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
/**
* Cookie "Secure" flag mode:
- * - true → always set Secure (HTTPS-only — cookie won't be sent over HTTP at all)
- * - false → never set Secure (allow plain HTTP — cookie has no in-flight protection)
+ * - true → always set Secure (HTTPS-only)
+ * - false → never set Secure (allow plain HTTP)
* - 'auto' → decide per-request based on req.secure (X-Forwarded-Proto
- * via Express `trust proxy`). Emits Secure when actual HTTPS is
- * detected, omits it on plain HTTP. This is the right default
- * for deployments reachable via both HTTPS (reverse proxy) and
- * LAN HTTP, and for first-time installs that haven't set up a
- * reverse proxy yet.
+ * via Express `trust proxy`). Useful when the same deployment
+ * is reachable over both HTTPS (via reverse proxy) and LAN HTTP.
*
- * Default:
- * - production → 'auto' (#427: previously hard `true`, which caused silent
- * login loops over HTTP because the browser drops the
- * Secure cookie. 'auto' is strictly more lenient than `true`
- * on real HTTPS — req.secure is true → Secure flag still
- * emitted — so this is not a security regression for
- * reverse-proxy deployments. Users who explicitly want the
- * HTTPS-only behaviour can still set COOKIE_SECURE=true.)
- * - dev → false (allow http://localhost in browsers without HSTS gymnastics)
+ * Default: follows NODE_ENV (production → true, dev → false) — unchanged
+ * from previous behavior. Users who want the auto mode must opt in with
+ * COOKIE_SECURE=auto in their .env.
*/
const secureCookieMode = (() => {
const raw = typeof process.env.COOKIE_SECURE === 'string'
@@ -33,10 +29,8 @@ const secureCookieMode = (() => {
if (raw === 'auto') return 'auto';
if (raw === 'true') return true;
if (raw === 'false') return false;
- // No env var set → infer from NODE_ENV. Production defaults to 'auto'
- // (per-request) rather than hard `true` so first-time HTTP installs don't
- // silently fail (#427).
- return process.env.NODE_ENV === 'production' ? 'auto' : false;
+ // No env var set → legacy default
+ return process.env.NODE_ENV === 'production';
})();
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
const cookieDomain = process.env.COOKIE_DOMAIN;
@@ -104,6 +98,49 @@ function sanitizeSlugForCookie(slug = '') {
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
}
+/**
+ * Best-effort decode of a JWT payload WITHOUT verifying the signature.
+ * Used by the token-extraction helpers below to peek at the `type` claim
+ * so we can decide whether a given Authorization Bearer header is the
+ * RIGHT type of token for the caller. Signature verification still
+ * happens at the route layer via jwt.verify; this peek only filters
+ * out wrong-type tokens.
+ *
+ * Returns null on any parse error so the helpers fall through to cookies
+ * rather than mis-routing to the wrong token type.
+ */
+function peekTokenType(token) {
+ if (typeof token !== 'string') return null;
+ const parts = token.split('.');
+ if (parts.length !== 3) return null;
+ try {
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf8'));
+ return typeof payload.type === 'string' ? payload.type : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Pull a Bearer token from the Authorization header IFF it claims the
+ * expected `type`. Otherwise null.
+ *
+ * Why: when an admin and a customer are logged in the same browser, the
+ * admin's `admin_token` is read from sessionStorage by the events service
+ * and attached as `Authorization: Bearer ` on requests
+ * unrelated to the admin surface. Without a type-check here, the
+ * gallery-side `/auth/session?slug=…` would happily return that admin
+ * token, decode it as `type:'admin'`, and report the wrong identity —
+ * which is exactly what defeated the prefer-gallery precedence fix on
+ * the dual-cookie test.
+ */
+function getBearerTokenIfType(req, expectedType) {
+ const header = req.headers?.authorization;
+ if (!header || !header.startsWith('Bearer ')) return null;
+ const token = header.substring(7);
+ return peekTokenType(token) === expectedType ? token : null;
+}
+
function setAdminAuthCookie(res, token) {
if (!token) return;
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res));
@@ -113,6 +150,15 @@ function clearAdminAuthCookie(res) {
res.clearCookie(ADMIN_COOKIE_NAME, buildClearCookieOptions());
}
+function setCustomerAuthCookie(res, token) {
+ if (!token) return;
+ res.cookie(CUSTOMER_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res));
+}
+
+function clearCustomerAuthCookie(res) {
+ res.clearCookie(CUSTOMER_COOKIE_NAME, buildClearCookieOptions());
+}
+
function setGalleryAuthCookies(res, token, slug) {
if (!token) return;
const options = buildCookieOptionsWithExpiry(res);
@@ -142,18 +188,44 @@ function clearGalleryAuthCookies(res, slug) {
}
function getAdminTokenFromRequest(req) {
- const header = req.headers?.authorization;
- if (header && header.startsWith('Bearer ')) {
- return header.substring(7);
- }
+ // Honour Authorization: Bearer only if the JWT claims type:'admin'.
+ // Stops gallery/customer tokens that happen to be on the request from
+ // being mistaken for admin auth (mirrors getGalleryTokenFromRequest's
+ // protection in the other direction).
+ const bearer = getBearerTokenIfType(req, 'admin');
+ if (bearer) return bearer;
return req.cookies?.[ADMIN_COOKIE_NAME] || null;
}
+/**
+ * Customer-side equivalent. Cookie-only — we deliberately do NOT honour
+ * the Authorization: Bearer header on /api/customer/* endpoints.
+ *
+ * Reason: admins occasionally hit the customer routes from the same
+ * browser (e.g. while dogfooding the dashboard). The shared axios
+ * client picks up the admin's token from `admin_token` and attaches it
+ * as `Authorization: Bearer ` for every request. If we
+ * accepted that header here, a logged-in admin's `'admin'` token would
+ * be returned and immediately rejected by the type check downstream as
+ * "wrong token type" — kicking the customer out on every reload.
+ *
+ * Customers don't have an API-token flow, so dropping the header
+ * fallback costs nothing and prevents the cross-contamination.
+ */
+function getCustomerTokenFromRequest(req) {
+ return req.cookies?.[CUSTOMER_COOKIE_NAME] || null;
+}
+
function getGalleryTokenFromRequest(req, slug) {
- const header = req.headers?.authorization;
- if (header && header.startsWith('Bearer ')) {
- return header.substring(7);
- }
+ // Honour Authorization: Bearer only if the JWT claims type:'gallery'.
+ // The previous unconditional Bearer pickup defeated the prefer-gallery
+ // precedence fix on /auth/session?slug=…: an admin token attached as
+ // Bearer (e.g. by the shared events.service.ts auto-auth path) was
+ // returned here, decoded as type:'admin' downstream, and mis-rendered
+ // the gallery as "logged in as admin" which kicked the customer back
+ // to the per-event password prompt.
+ const bearer = getBearerTokenIfType(req, 'gallery');
+ if (bearer) return bearer;
if (!req.cookies) {
return null;
@@ -209,12 +281,16 @@ module.exports = {
GALLERY_COOKIE_NAME,
GALLERY_COOKIE_PREFIX,
GUEST_COOKIE_PREFIX,
+ CUSTOMER_COOKIE_NAME,
sanitizeSlugForCookie,
setAdminAuthCookie,
clearAdminAuthCookie,
+ setCustomerAuthCookie,
+ clearCustomerAuthCookie,
setGalleryAuthCookies,
clearGalleryAuthCookies,
getAdminTokenFromRequest,
+ getCustomerTokenFromRequest,
getGalleryTokenFromRequest,
getGuestTokenFromRequest,
};
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index bda78611..fe2b0247 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -22,9 +22,23 @@ import {
AnalyticsPage,
SettingsPage,
UserManagementPage,
+ CustomerManagementPage,
+ CustomerDetailPage,
WebhookDeliveriesPage
} from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
+import {
+ CustomerLoginPage,
+ CustomerDashboardPage,
+ CustomerAcceptInvitePage,
+ CustomerLayout,
+ CustomerProfilePage,
+ CustomerCalendarPage,
+ CustomerQuotesPage,
+ CustomerBillsPage,
+ CustomerResetPasswordPage,
+} from './pages/customer';
+import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { RequireFeature } from './components/admin/RequireFeature';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
@@ -132,6 +146,13 @@ function App() {
}>
} />
+ {/* Customer accounts (#354) — admin-side management.
+ Hidden from sidebar + redirected away when the
+ customerPortal flag is off. */}
+ }>
+ } />
+ } />
+
} />
} />
@@ -153,6 +174,38 @@ function App() {
{/* Public invitation acceptance page */}
} />
+ {/* Customer surface (#354). Strictly separate provider /
+ cookie / API surface from /admin/*. Gated by the
+ customerPortal feature flag — when off, all
+ /customer/* URLs redirect to /admin/dashboard. */}
+ }>
+
+
+ {/* Public surfaces: login, accept-invite, reset —
+ no CustomerLayout (their own branded shells). */}
+ } />
+ } />
+ } />
+
+ {/* Authenticated surfaces share the sidebar layout
+ (Outlet pattern, mirrors AdminLayout). The
+ CustomerLayout itself enforces auth — bouncing
+ unauthenticated visitors to /customer/login. */}
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ } />
+
+
+ } />
+
+
{/* Public legal pages */}
} />
} />
diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx
index 187e17f1..2ae1cd8f 100644
--- a/frontend/src/components/admin/AdminSidebar.tsx
+++ b/frontend/src/components/admin/AdminSidebar.tsx
@@ -8,6 +8,7 @@ import {
Settings,
X,
Users,
+ UserCog,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -46,6 +47,12 @@ const navigation: NavItem[] = [
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
+ // Customer accounts (#354) — separate from admin users (#users.view)
+ // by design: customers log in at /customer/login with their own
+ // cookie + token type. Hidden when the customerPortal feature flag
+ // is OFF (Settings → Features). The corresponding /customer/* routes
+ // also redirect away in that case (see RequireFeature in App.tsx).
+ { nameKey: 'navigation.customers', href: '/admin/customers', icon: UserCog, permission: 'customers.view', featureFlag: 'customerPortal' },
];
export const AdminSidebar: React.FC = ({ isOpen, onClose }) => {
diff --git a/frontend/src/components/admin/CustomerAccountPicker.tsx b/frontend/src/components/admin/CustomerAccountPicker.tsx
new file mode 100644
index 00000000..4fdb2a27
--- /dev/null
+++ b/frontend/src/components/admin/CustomerAccountPicker.tsx
@@ -0,0 +1,202 @@
+/**
+ * CustomerAccountPicker (#354).
+ *
+ * Multi-select autocomplete used on the event create / edit forms to
+ * assign customer accounts to an event. Anyone selected here gets
+ * dashboard access + can bypass the per-event password.
+ *
+ * Backed by GET /api/admin/customers/search (debounced 200ms).
+ * Selected values render as removable chips so the form can stay compact.
+ */
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { Search, X, UserPlus } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { customerAdminService, type CustomerAccountSummary } from '../../services/customerAdmin.service';
+import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
+
+export interface SelectedCustomer {
+ id: number;
+ email: string;
+ displayName: string | null;
+}
+
+interface Props {
+ value: SelectedCustomer[];
+ onChange: (next: SelectedCustomer[]) => void;
+ disabled?: boolean;
+}
+
+const labelFor = (c: { email: string; displayName?: string | null; companyName?: string | null }) => {
+ const display = c.displayName?.trim() || c.companyName?.trim();
+ return display ? `${display} · ${c.email}` : c.email;
+};
+
+export const CustomerAccountPicker: React.FC = ({ value, onChange, disabled }) => {
+ const { t } = useTranslation();
+ const customerPortalEnabled = useFeatureEnabled('customerPortal');
+
+ // Gate the entire picker on the customerPortal feature flag. When off,
+ // the backend returns 410 on /admin/customers/search anyway, but hiding
+ // the UI here keeps the event form clean and removes the dangling
+ // "Customer accounts" label that would otherwise appear above an
+ // empty/error placeholder.
+ if (!customerPortalEnabled) return null;
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [isOpen, setIsOpen] = useState(false);
+ const [isSearching, setIsSearching] = useState(false);
+ const containerRef = useRef(null);
+
+ // Debounced search. Aborts in-flight requests so a fast typer doesn't
+ // see an old result win the race over a newer one.
+ useEffect(() => {
+ const term = query.trim();
+ if (!term) {
+ setResults([]);
+ setIsSearching(false);
+ return;
+ }
+ setIsSearching(true);
+ let cancelled = false;
+ const handle = window.setTimeout(async () => {
+ try {
+ const rows = await customerAdminService.search(term);
+ if (!cancelled) {
+ // Filter out already-selected ids on the client. Cheaper than
+ // round-tripping the selection state to the server.
+ const selectedIds = new Set(value.map((v) => v.id));
+ setResults(rows.filter((r) => !selectedIds.has(r.id)));
+ }
+ } catch {
+ if (!cancelled) setResults([]);
+ } finally {
+ if (!cancelled) setIsSearching(false);
+ }
+ }, 200);
+ return () => { cancelled = true; window.clearTimeout(handle); };
+ }, [query, value]);
+
+ // Click-outside to close. Listening on mousedown matches what the
+ // existing AdminHeader notification dropdown uses.
+ useEffect(() => {
+ const onDown = (e: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', onDown);
+ return () => document.removeEventListener('mousedown', onDown);
+ }, []);
+
+ const select = (c: CustomerAccountSummary) => {
+ onChange([...value, { id: c.id, email: c.email, displayName: c.displayName }]);
+ setQuery('');
+ setResults([]);
+ setIsOpen(false);
+ };
+
+ const remove = (id: number) => {
+ onChange(value.filter((v) => v.id !== id));
+ };
+
+ const helpText = useMemo(
+ () => t(
+ 'events.customerPicker.help',
+ 'Customers added here can log in at /customer/login and view this gallery without entering the per-event password.'
+ ),
+ [t]
+ );
+
+ return (
+
+
+ {t('events.customerPicker.label', 'Customer accounts')}
+
+
{helpText}
+
+ {/* Selected chips */}
+ {value.length > 0 && (
+
+ {value.map((c) => (
+
+ {c.displayName?.trim() || c.email}
+ {c.displayName?.trim() && c.email !== c.displayName && (
+ · {c.email}
+ )}
+ {!disabled && (
+ remove(c.id)}
+ className="ml-1 -mr-1 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 p-0.5"
+ aria-label={t('events.customerPicker.removeAria', 'Remove {{name}}', { name: c.email })}
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Search input */}
+
+
+ { setQuery(e.target.value); setIsOpen(true); }}
+ onFocus={() => setIsOpen(true)}
+ disabled={disabled}
+ placeholder={t('events.customerPicker.placeholder', 'Search by email, name, or company')}
+ className="input pl-9"
+ />
+
+
+ {/* Dropdown */}
+ {isOpen && query.trim() !== '' && (
+
+ {isSearching ? (
+
+ {t('events.customerPicker.searching', 'Searching…')}
+
+ ) : results.length === 0 ? (
+
+ {t('events.customerPicker.noResults', 'No matches. Invite this customer from /admin/customers first.')}
+
+ ) : (
+
+ {results.map((r) => (
+
+ select(r)}
+ className="w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
+ >
+
+ {labelFor(r)}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ );
+};
+
+export default CustomerAccountPicker;
diff --git a/frontend/src/contexts/CustomerAuthContext.tsx b/frontend/src/contexts/CustomerAuthContext.tsx
new file mode 100644
index 00000000..a21e2043
--- /dev/null
+++ b/frontend/src/contexts/CustomerAuthContext.tsx
@@ -0,0 +1,203 @@
+/**
+ * Customer-side React auth context (#354).
+ *
+ * Sibling of AdminAuthContext / GalleryAuthContext but operates on a
+ * separate cookie (customer_token) and a separate API surface
+ * (/api/customer/auth/*). The contexts are isolated by design so that
+ * a single browser can hold an admin session AND a customer session
+ * without one clobbering the other (e.g. for the admin dogfooding the
+ * customer dashboard).
+ *
+ * #354 follow-up: also surfaces the effective feature set and the
+ * branding visibility flags so CustomerLayout can render the sidebar
+ * without an extra round trip on every navigation.
+ */
+import React, { createContext, useContext, useEffect, useState } from 'react';
+import type { ReactNode } from 'react';
+import { customerService, type CustomerProfile } from '../services/customer.service';
+
+export interface CustomerFeatureFlags {
+ calendar: boolean;
+ quotes: boolean;
+ bills: boolean;
+}
+
+export interface CustomerBrandingFlags {
+ showLogo: boolean;
+ showCompanyName: boolean;
+}
+
+interface CustomerAuthContextType {
+ isAuthenticated: boolean;
+ customer: CustomerProfile | null;
+ features: CustomerFeatureFlags;
+ branding: CustomerBrandingFlags;
+ isLoading: boolean;
+ error: string | null;
+ /** Replaces the cached profile after a successful POST /login. */
+ setCustomer: (c: CustomerProfile) => void;
+ /**
+ * Replaces customer + features + branding atomically. Used by the
+ * login page so the dashboard's first paint after login shows the
+ * correct sidebar (without this, features default to `false` and the
+ * Soon menus would only appear after the next CustomerAuthProvider
+ * re-mount, e.g. after navigating to a gallery and back).
+ */
+ setSession: (s: { customer: CustomerProfile; features: CustomerFeatureFlags; branding: CustomerBrandingFlags }) => void;
+ logout: () => Promise;
+}
+
+const CustomerAuthContext = createContext(undefined);
+
+export const useCustomerAuth = () => {
+ const ctx = useContext(CustomerAuthContext);
+ if (!ctx) {
+ throw new Error('useCustomerAuth must be used within a CustomerAuthProvider');
+ }
+ return ctx;
+};
+
+const STORAGE_KEY = 'customer_profile';
+const FEATURES_KEY = 'customer_features';
+const BRANDING_KEY = 'customer_branding';
+
+const DEFAULT_FEATURES: CustomerFeatureFlags = { calendar: false, quotes: false, bills: false };
+const DEFAULT_BRANDING: CustomerBrandingFlags = { showLogo: true, showCompanyName: true };
+
+interface ProviderProps { children: ReactNode; }
+
+export const CustomerAuthProvider: React.FC = ({ children }) => {
+ const [customer, setCustomerState] = useState(null);
+ const [features, setFeatures] = useState(DEFAULT_FEATURES);
+ const [branding, setBranding] = useState(DEFAULT_BRANDING);
+ const [isLoading, setIsLoading] = useState(true);
+ // Reserved for future surface-level errors (login form errors are
+ // handled inline on the login page itself, not here).
+ const [error] = useState(null);
+
+ /**
+ * Refetch the session from /api/customer/auth/session and update both
+ * React state and sessionStorage caches. Called on initial mount AND
+ * on window focus, so an admin who toggles a per-customer feature in
+ * one tab sees the change reflected in the customer tab the moment
+ * they switch back. Without this, the layout reads only from the
+ * mount-time sessionStorage cache and stays stale until a hard reload.
+ */
+ const refreshSession = React.useCallback(async () => {
+ try {
+ const response = await customerService.session();
+ if (response?.customer) {
+ setCustomerState(response.customer);
+ setFeatures(response.features);
+ setBranding(response.branding);
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(response.customer));
+ sessionStorage.setItem(FEATURES_KEY, JSON.stringify(response.features));
+ sessionStorage.setItem(BRANDING_KEY, JSON.stringify(response.branding));
+ } else {
+ setCustomerState(null);
+ sessionStorage.removeItem(STORAGE_KEY);
+ sessionStorage.removeItem(FEATURES_KEY);
+ sessionStorage.removeItem(BRANDING_KEY);
+ }
+ } catch {
+ setCustomerState(null);
+ sessionStorage.removeItem(STORAGE_KEY);
+ sessionStorage.removeItem(FEATURES_KEY);
+ sessionStorage.removeItem(BRANDING_KEY);
+ }
+ }, []);
+
+ useEffect(() => {
+ // Hydrate immediately from sessionStorage so the dashboard avoids
+ // a flicker on hard refresh; the network call below confirms the
+ // cookie is still valid and overwrites stale data.
+ try {
+ const cached = sessionStorage.getItem(STORAGE_KEY);
+ if (cached) setCustomerState(JSON.parse(cached));
+ const cachedFeatures = sessionStorage.getItem(FEATURES_KEY);
+ if (cachedFeatures) setFeatures(JSON.parse(cachedFeatures));
+ const cachedBranding = sessionStorage.getItem(BRANDING_KEY);
+ if (cachedBranding) setBranding(JSON.parse(cachedBranding));
+ } catch {
+ sessionStorage.removeItem(STORAGE_KEY);
+ sessionStorage.removeItem(FEATURES_KEY);
+ sessionStorage.removeItem(BRANDING_KEY);
+ }
+
+ let cancelled = false;
+ refreshSession().finally(() => {
+ if (!cancelled) setIsLoading(false);
+ });
+
+ // Refetch on tab/window focus so admin-side changes (per-customer
+ // feature toggles, branding visibility, deactivation) reach the
+ // customer browser without requiring a manual page reload.
+ const onFocus = () => { void refreshSession(); };
+ const onVisibility = () => {
+ if (document.visibilityState === 'visible') void refreshSession();
+ };
+ window.addEventListener('focus', onFocus);
+ document.addEventListener('visibilitychange', onVisibility);
+
+ // Periodic background refresh — covers the case where the customer
+ // tab stays foregrounded for a long stretch (no focus/visibility
+ // events fire) but admin has flipped a global toggle in another
+ // browser. 60 seconds matches the usePublicSettings react-query
+ // staleTime so branding + feature flags stay roughly in sync.
+ const interval = window.setInterval(() => {
+ if (document.visibilityState === 'visible') void refreshSession();
+ }, 60_000);
+
+ return () => {
+ cancelled = true;
+ window.removeEventListener('focus', onFocus);
+ document.removeEventListener('visibilitychange', onVisibility);
+ window.clearInterval(interval);
+ };
+ }, [refreshSession]);
+
+ const setCustomer = (c: CustomerProfile) => {
+ setCustomerState(c);
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(c));
+ };
+
+ const setSession = (s: { customer: CustomerProfile; features: CustomerFeatureFlags; branding: CustomerBrandingFlags }) => {
+ setCustomerState(s.customer);
+ setFeatures(s.features);
+ setBranding(s.branding);
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s.customer));
+ sessionStorage.setItem(FEATURES_KEY, JSON.stringify(s.features));
+ sessionStorage.setItem(BRANDING_KEY, JSON.stringify(s.branding));
+ };
+
+ const logout = async () => {
+ await customerService.logout();
+ setCustomerState(null);
+ setFeatures(DEFAULT_FEATURES);
+ setBranding(DEFAULT_BRANDING);
+ sessionStorage.removeItem(STORAGE_KEY);
+ sessionStorage.removeItem(FEATURES_KEY);
+ sessionStorage.removeItem(BRANDING_KEY);
+ // Hard navigate so any in-flight requests with the old cookie don't
+ // race the cleared session — same approach AdminAuthContext uses.
+ window.location.href = '/customer/login';
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx
index 57515f7f..da78d965 100644
--- a/frontend/src/contexts/FeatureFlagsContext.tsx
+++ b/frontend/src/contexts/FeatureFlagsContext.tsx
@@ -19,6 +19,12 @@ export const DEFAULT_FLAGS: FeatureFlags = {
messaging: false,
analytics: true,
userManagement: true,
+ // Customer portal (#354) defaults OFF on a fresh install — picpeak
+ // ships as a focused gallery delivery tool, recurring-customer
+ // logins are opt-in. Migration 094 flips this to TRUE on existing
+ // installs (events>0) so the customer-portal foundation isn't
+ // silently disabled mid-deployment.
+ customerPortal: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
@@ -50,9 +56,27 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
out.galleries = true; // foundation — always on
if (out.quotes === false) out.bills = false; // bills depend on quotes
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
+ // Customer-portal-dependent flags: if the customer portal is OFF the
+ // customer-side dashboard never renders, so the calendar/quotes/bills/
+ // messaging customer-side surfaces have nowhere to live. Their server
+ // toggles can stay at whatever the admin set (so re-enabling the
+ // portal restores the previous state), but the dependency is
+ // documented here for the FeaturesTab UI to disable child cards
+ // visually when customerPortal is off.
return out;
}
+/**
+ * Flags whose customer-side surface only renders when the customer
+ * portal is on. The FeaturesTab uses this to disable the toggle on
+ * child cards when customerPortal=false (with a "requires Customer
+ * portal" tooltip), so the admin doesn't flip something that has no
+ * visible effect.
+ */
+export const CUSTOMER_PORTAL_DEPENDENT_FLAGS: FeatureKey[] = [
+ 'calendar', 'calendarBooking', 'quotes', 'bills', 'messaging',
+];
+
function flagsEqual(a: FeatureFlags, b: FeatureFlags): boolean {
return (Object.keys(a) as FeatureKey[]).every((k) => a[k] === b[k]);
}
diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx
index 16435236..62ac2e7e 100644
--- a/frontend/src/features/settings/tabs/FeaturesTab.tsx
+++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx
@@ -11,6 +11,7 @@ import {
Receipt,
BarChart3,
Users,
+ UserCog,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../../components/common';
@@ -107,6 +108,26 @@ export const FeaturesTab: React.FC = () => {
/>
+ {/* Customer accounts (#354) — recurring customer logins. The
+ calendar / quotes / bills / messaging cards below are
+ customer-side surfaces; they only render in the customer
+ dashboard when this is on. */}
+
+ setFlag('customerPortal', next)}
+ />
+
+
{/* Communication */}
and rendered inside the customer surface picks
+ * up var(--color-surface) / var(--color-text) automatically — no
+ * per-page wrapper needed. Same treatment for native
+ * elements which share the .input mental model on the profile and
+ * accept-invite forms.
+ *
+ * The .customer-surface marker is set on the root of every
+ * customer page (CustomerLayout, CustomerLoginPage, CustomerAcceptInvitePage,
+ * CustomerResetPasswordPage). Pages outside this scope (admin, public
+ * gallery) keep their original styling.
+ */
+ .customer-surface .card,
+ .customer-surface .card-hover {
+ background-color: var(--color-surface);
+ border-color: var(--color-surface-border);
+ }
+
+ .customer-surface .input,
+ .customer-surface select.input,
+ .customer-surface input.input {
+ background-color: var(--color-surface);
+ border-color: var(--color-surface-border);
+ color: var(--color-text);
+ }
+
+ .customer-surface .input::placeholder {
+ color: var(--color-muted-text);
+ opacity: 0.7;
+ }
+
+ .customer-surface .input:disabled {
+ background-color: var(--color-elevated, var(--color-surface));
+ color: var(--color-muted-text);
+ opacity: 0.7;
+ }
+
.dark .input-themed {
@apply bg-neutral-800 border-neutral-700 text-neutral-100;
}
diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx
index 3ad86283..bbcd8058 100644
--- a/frontend/src/pages/admin/CreateEventPage.tsx
+++ b/frontend/src/pages/admin/CreateEventPage.tsx
@@ -17,6 +17,7 @@ import { toast } from 'react-toastify';
import { Button, Input, Card, PasswordGenerator } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
+import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { useMutation, useQuery } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
@@ -68,6 +69,10 @@ interface FormData {
client_password: string;
// Default photo sort
default_photo_sort: string;
+ // Customer accounts assigned to this event (#354). The state holds
+ // the full picker selection so chips render without an extra fetch;
+ // only the ids are sent to the backend on submit.
+ customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
}
// Fallback event types (used when API is unavailable)
@@ -127,6 +132,7 @@ export const CreateEventPage: React.FC = () => {
client_access_enabled: false,
client_password: '',
default_photo_sort: 'upload_date_desc',
+ customer_accounts: [],
});
const [errors, setErrors] = useState
>>({});
@@ -437,6 +443,10 @@ export const CreateEventPage: React.FC = () => {
client_password: formData.client_access_enabled ? formData.client_password : undefined,
// Default photo sort
default_photo_sort: formData.default_photo_sort,
+ // Customer accounts assigned to this event (#354). Sent as a flat
+ // array of ids; the backend service diffs against the existing
+ // assignments and applies adds/removes inside one transaction.
+ customer_account_ids: formData.customer_accounts.map((c) => c.id),
};
createMutation.mutate(payload);
@@ -755,6 +765,15 @@ export const CreateEventPage: React.FC = () => {
/>
)}
+ {/* Customer accounts (#354). The picker is decoupled from
+ the freeform customer_name / customer_email fields above
+ — those stay as the event's primary contact while
+ customer_account_ids drives login-level access. */}
+ setFormData((prev) => ({ ...prev, customer_accounts: next }))}
+ />
+
{
+ if (!iso) return '—';
+ try { return format(new Date(iso), 'PP'); } catch { return '—'; }
+};
+
+export const CustomerDetailPage: React.FC = () => {
+ const { t } = useTranslation();
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const customerId = Number(id);
+
+ const { data: customer, isLoading, error } = useQuery({
+ queryKey: ['admin-customer', customerId],
+ queryFn: () => customerAdminService.get(customerId),
+ enabled: Number.isFinite(customerId) && customerId > 0,
+ });
+
+ const [form, setForm] = useState>>({});
+ const [confirmDeactivate, setConfirmDeactivate] = useState(false);
+ const [confirmErase, setConfirmErase] = useState(false);
+
+ // Hydrate the form from the fetched record once. We deliberately do NOT
+ // re-sync on every refetch so an admin's in-progress edits aren't blown
+ // away by a background refresh.
+ useEffect(() => {
+ if (customer && Object.keys(form).length === 0) {
+ setForm({
+ email: customer.email,
+ salutation: customer.salutation,
+ firstName: customer.firstName,
+ lastName: customer.lastName,
+ displayName: customer.displayName,
+ phone: customer.phone,
+ companyName: customer.companyName,
+ billingEmail: customer.billingEmail,
+ vatId: customer.vatId,
+ addressLine1: customer.addressLine1,
+ addressLine2: customer.addressLine2,
+ postalCode: customer.postalCode,
+ city: customer.city,
+ state: customer.state,
+ countryCode: customer.countryCode,
+ preferredLanguage: customer.preferredLanguage,
+ notes: customer.notes,
+ featureCalendar: customer.featureCalendar ?? false,
+ featureQuotes: customer.featureQuotes ?? false,
+ featureBills: customer.featureBills ?? false,
+ } as any);
+ }
+ }, [customer, form]);
+
+ const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills') => {
+ setForm((prev) => ({ ...prev, [key]: !prev[key] }) as any);
+ };
+
+ const setField = (key: EditableFields) => (e: React.ChangeEvent) =>
+ setForm((prev) => ({ ...prev, [key]: e.target.value }));
+
+ const saveMutation = useMutation({
+ mutationFn: () => customerAdminService.update(customerId, form),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(['admin-customer', customerId], updated);
+ queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
+ toast.success(t('customers.detail.saved', 'Customer saved'));
+ },
+ onError: (e: any) => {
+ const msg = e?.response?.status === 409
+ ? t('customers.detail.emailConflict', 'That email is already in use by another customer.')
+ : e?.response?.data?.error || t('customers.detail.saveError', 'Could not save changes.');
+ toast.error(msg);
+ },
+ });
+
+ /**
+ * Trigger a password-reset email. Reused permission `customers.create`
+ * server-side because issuing a reset is the same authority level as
+ * issuing an invitation (both put a credential in the customer's mailbox).
+ * Confirm dialog ahead of the click is surfaced via the same modal
+ * pattern as deactivate.
+ */
+ const passwordResetMutation = useMutation({
+ mutationFn: () => customerAdminService.sendPasswordReset(customerId),
+ onSuccess: () => toast.success(t('customers.detail.passwordReset.success', 'Password reset email sent')),
+ onError: () => toast.error(t('customers.detail.passwordReset.error', 'Could not send password reset')),
+ });
+
+ const deactivateMutation = useMutation({
+ mutationFn: () => customerAdminService.deactivate(customerId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
+ queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
+ toast.success(t('customers.deactivate.success', 'Customer deactivated'));
+ navigate('/admin/customers');
+ },
+ onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
+ });
+
+ /** Re-enable login for a deactivated customer. */
+ const reactivateMutation = useMutation({
+ mutationFn: () => customerAdminService.reactivate(customerId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
+ queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
+ toast.success(t('customers.reactivate.success', 'Customer reactivated'));
+ },
+ onError: () => toast.error(t('customers.reactivate.error', 'Could not reactivate customer')),
+ });
+
+ /**
+ * Anonymize-in-place erasure. Two-step UX: requires the customer to be
+ * deactivated first, then a separate confirm modal. Hard delete is
+ * deliberately NOT exposed — see service notes for why.
+ */
+ const eraseMutation = useMutation({
+ mutationFn: () => customerAdminService.erase(customerId),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
+ queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
+ toast.success(t('customers.erase.success', 'Customer erased'));
+ navigate('/admin/customers');
+ },
+ onError: () => toast.error(t('customers.erase.error', 'Could not erase customer')),
+ });
+
+ if (isLoading) {
+ return
;
+ }
+ if (error || !customer) {
+ return (
+
+
+
+ {t('customers.detail.loadError', 'Could not load customer')}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {customer.displayName || customer.email}
+
+
{customer.email}
+
+
+
+ {customer.isActive ? (
+
+
+ {t('customers.status.active', 'Active')}
+
+ ) : (
+
+
+ {t('customers.status.inactive', 'Deactivated')}
+
+ )}
+
+
+
+ {/* Account section */}
+
+
+ {t('customers.detail.accountSection', 'Account')}
+
+
+
+ {t('customers.detail.email', 'Email')}
+
+
+
+ {t('customers.detail.preferredLanguage', 'Preferred language')}
+
+ English
+ Deutsch
+ Nederlands
+ Português
+ Русский
+
+
+
+
+
+ {/* Personal section */}
+
+
+ {t('customers.detail.personalSection', 'Personal information')}
+
+
+
+ {t('customers.detail.salutation', 'Salutation')}
+ {/* Salutation values are stored verbatim in the DB ("Herr",
+ "Frau", "Mx", "Dr") — those are the canonical token values
+ across locales. Display labels are translated; the value
+ attribute stays in the German form so existing rows
+ remain valid regardless of which locale the admin is
+ viewing the dropdown in. */}
+
+ {t('customer.profile.salutation.none', '— Not specified —')}
+ {t('customer.profile.salutation.herr', 'Mr.')}
+ {t('customer.profile.salutation.frau', 'Ms.')}
+ {t('customer.profile.salutation.mx', 'Mx')}
+ {t('customer.profile.salutation.dr', 'Dr.')}
+
+
+
+ {t('customers.detail.firstName', 'First name')}
+
+
+
+ {t('customers.detail.lastName', 'Last name')}
+
+
+
+ {t('customers.detail.displayName', 'Display name')}
+
+
+
+
+ {t('customers.detail.phone', 'Phone')}
+
+
+
+
+
+ {t('customers.detail.company', 'Company')}
+
+
+
+
+
+
+ {/* Address + billing */}
+
+
+ {t('customers.detail.billingSection', 'Address & billing')}
+
+
+
+
+ {/* Per-customer feature flags (#354 follow-up) */}
+
+
+
+ {t('customers.detail.featuresSection', 'Customer features')}
+
+
+ {t(
+ 'customers.detail.featuresHint',
+ 'Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Features are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.'
+ )}
+
+
+ {([
+ { key: 'featureCalendar', labelKey: 'customer.nav.calendar', fallback: 'Calendar' },
+ { key: 'featureQuotes', labelKey: 'customer.nav.quotes', fallback: 'Quotes' },
+ { key: 'featureBills', labelKey: 'customer.nav.bills', fallback: 'Bills' },
+ ] as const).map(({ key, labelKey, fallback }) => {
+ const enabled = !!form[key];
+ return (
+
+
+ {t(labelKey, fallback)}
+ {/* Soon badge — these tabs are still coming-soon stubs;
+ this keeps the admin honest when looking at the
+ toggles. */}
+
+ {t('customer.nav.soon', 'Soon')}
+
+
+ toggleFeature(key)}
+ className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
+ style={{ backgroundColor: enabled ? 'var(--color-accent)' : 'var(--color-surface-border)' }}
+ >
+
+
+
+ );
+ })}
+
+
+
+ {/* Account actions: password reset (#354 follow-up) */}
+
+
+
+ {t('customers.detail.passwordSection', 'Account actions')}
+
+
+ {t(
+ 'customers.detail.passwordHint',
+ 'Sends a 7-day single-use reset link to the customer\'s email. The customer\'s current password keeps working until they click the link and set a new one.'
+ )}
+
+ }
+ isLoading={passwordResetMutation.isPending}
+ disabled={!customer.isActive}
+ onClick={() => passwordResetMutation.mutate()}
+ >
+ {t('customers.detail.passwordReset.button', 'Send password reset email')}
+
+ {!customer.isActive && (
+
+ {t('customers.detail.passwordReset.inactive', 'Reactivate the customer before sending a reset.')}
+
+ )}
+
+
+ {/* Notes (admin-only) */}
+
+
+ {t('customers.detail.notesSection', 'Internal notes')}
+
+
+ {t('customers.detail.notesHint', 'Visible only to admins. Never shown to the customer.')}
+
+
+
+
+ {/* Assigned events */}
+
+
+ {t('customers.detail.eventsSection', 'Assigned events')}
+
+ {customer.events.length === 0 ? (
+
+ {t('customers.detail.noEvents', 'Not assigned to any events yet. Add this customer to an event from the event form.')}
+
+ ) : (
+
+ {customer.events.map((ev) => (
+
+
+ {ev.eventName}
+
+
+ {ev.eventDate ? formatDate(ev.eventDate) : ''}
+ {ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${formatDate(ev.expiresAt)}` : ''}
+
+
+ ))}
+
+ )}
+
+
+ {/* Actions */}
+
+
+ {customer.isActive ? (
+ }
+ onClick={() => setConfirmDeactivate(true)}
+ >
+ {t('customers.deactivate.button', 'Deactivate')}
+
+ ) : (
+ <>
+ }
+ isLoading={reactivateMutation.isPending}
+ onClick={() => reactivateMutation.mutate()}
+ >
+ {t('customers.reactivate.button', 'Reactivate')}
+
+ {/* Erase is only offered when the customer is already
+ inactive — forces a deliberate two-step (deactivate
+ → erase) and removes the chance of misclicking through
+ the deactivate button on a live account. */}
+ }
+ onClick={() => setConfirmErase(true)}
+ >
+
+ {t('customers.erase.button', 'Erase customer data')}
+
+
+ >
+ )}
+
+
}
+ isLoading={saveMutation.isPending}
+ onClick={() => saveMutation.mutate()}
+ >
+ {t('customers.detail.save', 'Save changes')}
+
+
+
+ {confirmDeactivate && (
+
+
+
+
+
+
+
+ {t('customers.deactivate.title', 'Deactivate customer?')}
+
+
+ {t('customers.deactivate.body',
+ 'They will no longer be able to log in. You can re-activate or fully erase them later.')}
+
+
+
+
+ setConfirmDeactivate(false)}>
+ {t('common.cancel', 'Cancel')}
+
+ { deactivateMutation.mutate(); setConfirmDeactivate(false); }}
+ >
+ {t('common.confirm', 'Confirm')}
+
+
+
+
+
+ )}
+
+ {/* Erase confirm modal — second step after deactivate. Spelled out
+ "irreversible" copy + red Confirm button so the click feels
+ deliberate. The action anonymizes PII in place; assignments
+ and audit-log references are preserved. */}
+ {confirmErase && (
+
+
+
+
+
+
+
+ {t('customers.erase.title', 'Erase customer data?')}
+
+
+ {t('customers.erase.body',
+ 'Removes the customer\'s name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.')}
+
+
+
+
+ setConfirmErase(false)}>
+ {t('common.cancel', 'Cancel')}
+
+ { eraseMutation.mutate(); setConfirmErase(false); }}
+ >
+ {eraseMutation.isPending
+ ? t('customers.erase.confirmInFlight', 'Erasing…')
+ : t('customers.erase.confirm', 'Erase permanently')}
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default CustomerDetailPage;
diff --git a/frontend/src/pages/admin/CustomerManagementPage.tsx b/frontend/src/pages/admin/CustomerManagementPage.tsx
new file mode 100644
index 00000000..090cb930
--- /dev/null
+++ b/frontend/src/pages/admin/CustomerManagementPage.tsx
@@ -0,0 +1,597 @@
+/**
+ * Admin → Customer accounts management (#354).
+ *
+ * Mounted at /admin/customers. Listed in AdminSidebar gated on
+ * `customers.view` so only super_admin / admin see it.
+ *
+ * NOT a duplicate of UserManagementPage:
+ * - admin_users table (admin RBAC, token type 'admin', /admin/login)
+ * - customer_accounts table (per-event access, token type 'customer', /customer/login)
+ *
+ * The two pages share visual patterns (tabbed list + invite modal) but
+ * operate on completely different DB tables, services, auth surfaces,
+ * and permission models. The customer invite intentionally has no role
+ * picker (customers don't have roles — access is boolean per event,
+ * managed via the event form's CustomerAccountPicker).
+ */
+import React, { useMemo, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'react-toastify';
+import {
+ UserPlus, Mail, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
+} from 'lucide-react';
+import { format } from 'date-fns';
+
+import { Button, Card, Input, Loading } from '../../components/common';
+import {
+ customerAdminService,
+ type CustomerAccountSummary,
+ type CustomerInvitationSummary,
+} from '../../services/customerAdmin.service';
+
+type TabType = 'customers' | 'invitations';
+
+const formatDate = (iso: string | null | undefined) => {
+ if (!iso) return '—';
+ try { return format(new Date(iso), 'PP'); } catch { return '—'; }
+};
+
+/**
+ * Invite modal with optional prefill (#354 follow-up).
+ *
+ * Email is the only required field. Everything else is collected behind
+ * a "Add contact details" toggle so a fast invite stays one-click. When
+ * filled, the values are stashed on the invitation row and re-rendered
+ * pre-populated on the customer's accept page (where the customer can
+ * still edit before submitting).
+ */
+const InviteModal: React.FC<{
+ isOpen: boolean;
+ onClose: () => void;
+ onInvited: () => void;
+}> = ({ isOpen, onClose, onInvited }) => {
+ const { t } = useTranslation();
+ const [email, setEmail] = useState('');
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [showPrefill, setShowPrefill] = useState(false);
+ const [prefill, setPrefill] = useState({
+ salutation: '', first_name: '', last_name: '', display_name: '',
+ phone: '', company_name: '', vat_id: '',
+ address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
+ });
+
+ const updatePrefill = (key: keyof typeof prefill, value: string) => {
+ setPrefill((p) => ({ ...p, [key]: value }));
+ };
+
+ const reset = () => {
+ setEmail('');
+ setError(null);
+ setShowPrefill(false);
+ setPrefill({
+ salutation: '', first_name: '', last_name: '', display_name: '',
+ phone: '', company_name: '', vat_id: '',
+ address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
+ });
+ };
+
+ const submit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ setError(t('customers.invite.invalidEmail', 'Please enter a valid email'));
+ return;
+ }
+ setSubmitting(true);
+ try {
+ // Strip empty values so the backend stores `null`/nothing for fields
+ // the admin didn't actually fill in. Saves a round trip through
+ // the backend's sanitiser and keeps the JSON payload small.
+ const cleaned: Record = {};
+ for (const [k, v] of Object.entries(prefill)) {
+ const trimmed = v.trim();
+ if (trimmed) cleaned[k] = trimmed;
+ }
+ await customerAdminService.invite(
+ email.trim(),
+ Object.keys(cleaned).length > 0 ? cleaned : undefined,
+ );
+ toast.success(t('customers.invite.success', 'Invitation sent'));
+ reset();
+ onInvited();
+ onClose();
+ } catch (e: any) {
+ const msg = e?.response?.status === 409
+ ? t('customers.invite.conflict', 'A customer with this email already exists or has a pending invitation.')
+ : e?.response?.data?.error || t('customers.invite.error', 'Could not send invitation.');
+ setError(msg);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ if (!isOpen) return null;
+ return (
+
+
+
+
+
+ {t('customers.invite.title', 'Invite a customer')}
+
+ { reset(); onClose(); }}
+ className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
+ aria-label={t('common.close', 'Close')}
+ >
+
+
+
+
+ {t('customers.invite.description',
+ 'They\'ll receive an email with a link to set up their account. Once they\'ve accepted, you can assign them to events.')}
+
+
+
+
+
+ );
+};
+
+export const CustomerManagementPage: React.FC = () => {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const [activeTab, setActiveTab] = useState('customers');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [inviteOpen, setInviteOpen] = useState(false);
+ const [confirm, setConfirm] = useState<{ kind: 'deactivate'; id: number; name: string } | { kind: 'cancelInvite'; id: number; email: string } | null>(null);
+
+ const { data: customers, isLoading: customersLoading, error: customersError } = useQuery({
+ queryKey: ['admin-customers'],
+ queryFn: () => customerAdminService.list(),
+ });
+
+ const { data: invitations, isLoading: invitationsLoading, error: invitationsError } = useQuery({
+ queryKey: ['admin-customer-invitations'],
+ queryFn: () => customerAdminService.listInvitations(),
+ });
+
+ const filteredCustomers = useMemo(() => {
+ const list = customers || [];
+ if (!searchTerm.trim()) return list;
+ const term = searchTerm.trim().toLowerCase();
+ return list.filter((c) =>
+ c.email.toLowerCase().includes(term)
+ || (c.displayName || '').toLowerCase().includes(term)
+ || (c.lastName || '').toLowerCase().includes(term)
+ || (c.companyName || '').toLowerCase().includes(term)
+ );
+ }, [customers, searchTerm]);
+
+ const filteredInvitations = useMemo(() => {
+ const list = invitations || [];
+ if (!searchTerm.trim()) return list;
+ const term = searchTerm.trim().toLowerCase();
+ return list.filter((i) => i.email.toLowerCase().includes(term));
+ }, [invitations, searchTerm]);
+
+ const deactivateMutation = useMutation({
+ mutationFn: (id: number) => customerAdminService.deactivate(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
+ toast.success(t('customers.deactivate.success', 'Customer deactivated'));
+ },
+ onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
+ });
+
+ const cancelInviteMutation = useMutation({
+ mutationFn: (id: number) => customerAdminService.cancelInvitation(id),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] });
+ toast.success(t('customers.cancelInvitation.success', 'Invitation cancelled'));
+ },
+ onError: () => toast.error(t('customers.cancelInvitation.error', 'Could not cancel invitation')),
+ });
+
+ const renderCustomerName = (c: CustomerAccountSummary) => {
+ const display = c.displayName?.trim()
+ || [c.firstName, c.lastName].filter(Boolean).join(' ').trim()
+ || c.companyName?.trim();
+ return display || {t('customers.unnamed', 'Unnamed')} ;
+ };
+
+ const renderTabs = () => (
+
+ setActiveTab('customers')}
+ className={`pb-3 -mb-px border-b-2 text-sm font-medium ${
+ activeTab === 'customers' ? 'border-accent text-accent' : 'border-transparent text-muted-theme hover:text-theme'
+ }`}
+ >
+ {t('customers.tabs.customers', 'Customers')}
+ {customers ? ({customers.length}) : null}
+
+ setActiveTab('invitations')}
+ className={`pb-3 -mb-px border-b-2 text-sm font-medium ${
+ activeTab === 'invitations' ? 'border-accent text-accent' : 'border-transparent text-muted-theme hover:text-theme'
+ }`}
+ >
+ {t('customers.tabs.invitations', 'Invitations')}
+ {invitations ? ({invitations.length}) : null}
+
+
+ );
+
+ return (
+
+
+
+
+
{t('customers.pageTitle', 'Customers')}
+ {/* Beta badge — Calendar/Quotes/Bills tabs in the customer
+ surface are placeholders, so flag the whole feature as
+ still evolving. Keeps expectations honest. */}
+
+ {t('navigation.betaTag', 'Beta')}
+
+
+
+ {t('customers.pageSubtitle', 'Recurring customer accounts that can log in at /customer/login.')}
+
+
+
} onClick={() => setInviteOpen(true)}>
+ {t('customers.invite.button', 'Invite customer')}
+
+
+
+
+ {renderTabs()}
+
+
+ setSearchTerm(e.target.value)}
+ placeholder={t('customers.search.placeholder', 'Search by email, name, or company')}
+ leftIcon={ }
+ />
+
+
+ {activeTab === 'customers' ? (
+ customersLoading ? (
+
+ ) : customersError ? (
+
+
+ {t('customers.loadError', 'Could not load customers')}
+
+ ) : filteredCustomers.length === 0 ? (
+
+ {t('customers.empty', 'No customers yet. Click "Invite customer" to add one.')}
+
+ ) : (
+
+
+
+
+ {t('customers.table.name', 'Name')}
+ {t('customers.table.email', 'Email')}
+ {t('customers.table.company', 'Company')}
+ {t('customers.table.eventCount', 'Events')}
+ {t('customers.table.lastLogin', 'Last login')}
+ {t('customers.table.status', 'Status')}
+
+
+
+
+ {filteredCustomers.map((c) => (
+
+
+
+ {renderCustomerName(c)}
+
+
+ {c.email}
+ {c.companyName || '—'}
+ {c.eventCount ?? 0}
+ {formatDate(c.lastLogin)}
+
+ {c.isActive ? (
+
+
+ {t('customers.status.active', 'Active')}
+
+ ) : (
+
+
+ {t('customers.status.inactive', 'Deactivated')}
+
+ )}
+
+
+ {c.isActive && (
+ }
+ onClick={() => setConfirm({ kind: 'deactivate', id: c.id, name: c.email })}
+ >
+ {t('customers.deactivate.button', 'Deactivate')}
+
+ )}
+
+
+ ))}
+
+
+
+ )
+ ) : (
+ invitationsLoading ? (
+
+ ) : invitationsError ? (
+
+
+ {t('customers.loadInvitationsError', 'Could not load invitations')}
+
+ ) : filteredInvitations.length === 0 ? (
+
+ {t('customers.invitations.empty', 'No pending invitations.')}
+
+ ) : (
+
+
+
+
+ {t('customers.invitations.email', 'Email')}
+ {t('customers.invitations.invitedBy', 'Invited by')}
+ {t('customers.invitations.expiresAt', 'Expires')}
+ {t('customers.invitations.createdAt', 'Created')}
+
+
+
+
+ {filteredInvitations.map((inv: CustomerInvitationSummary) => (
+
+ {inv.email}
+ {inv.invitedBy || '—'}
+
+
+
+ {formatDate(inv.expiresAt)}
+
+
+ {formatDate(inv.createdAt)}
+
+ }
+ onClick={() => setConfirm({ kind: 'cancelInvite', id: inv.id, email: inv.email })}
+ >
+ {t('customers.invitations.cancel', 'Cancel')}
+
+
+
+ ))}
+
+
+
+ )
+ )}
+
+
+
setInviteOpen(false)}
+ onInvited={() => queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] })}
+ />
+
+ {confirm && (
+
+
+
+
+
+
+
+ {confirm.kind === 'deactivate'
+ ? t('customers.deactivate.title', 'Deactivate customer?')
+ : t('customers.cancelInvitation.title', 'Cancel invitation?')}
+
+
+ {confirm.kind === 'deactivate'
+ ? t('customers.deactivate.body',
+ 'They will no longer be able to log in. You can re-invite them later.')
+ : t('customers.cancelInvitation.body',
+ 'The invitation link will stop working immediately.')}
+
+
+
+
+ setConfirm(null)}>
+ {t('common.cancel', 'Cancel')}
+
+ {
+ if (confirm.kind === 'deactivate') {
+ deactivateMutation.mutate(confirm.id);
+ } else {
+ cancelInviteMutation.mutate(confirm.id);
+ }
+ setConfirm(null);
+ }}
+ isLoading={deactivateMutation.isPending || cancelInviteMutation.isPending}
+ >
+ {t('common.confirm', 'Confirm')}
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default CustomerManagementPage;
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx
index f7690355..f27a5019 100644
--- a/frontend/src/pages/admin/EventDetailsPage.tsx
+++ b/frontend/src/pages/admin/EventDetailsPage.tsx
@@ -59,6 +59,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading, MarkdownContent } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
+import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -290,6 +291,10 @@ export const EventDetailsPage: React.FC = () => {
// off → no promo for this event regardless of global
promo_mode: 'inherit' | 'custom' | 'off';
promo_markdown: string;
+ // Customer accounts assigned to this event (#354). Hydrated from
+ // the GET /admin/events/:id response and sent back as a flat id
+ // array on save.
+ customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
};
const [isEditing, setIsEditing] = useState(false);
@@ -330,6 +335,8 @@ export const EventDetailsPage: React.FC = () => {
// Per-event promotional override (#440)
promo_mode: 'inherit',
promo_markdown: '',
+ // Customer accounts (#354) — hydrated from event response.
+ customer_accounts: [],
});
const [feedbackSettings, setFeedbackSettings] = useState({
feedback_enabled: false,
@@ -588,6 +595,11 @@ export const EventDetailsPage: React.FC = () => {
// Per-event promotional override (#440)
promo_mode: ((event as { promo_mode?: 'inherit' | 'custom' | 'off' }).promo_mode) || 'inherit',
promo_markdown: (event as { promo_markdown?: string }).promo_markdown || '',
+ // Customer accounts (#354). The backend returns
+ // `customer_accounts: [{ id, email, display_name, ... }]`; map to
+ // the picker's shape.
+ customer_accounts: ((event as { customer_accounts?: Array<{ id: number; email: string; display_name?: string | null }> }).customer_accounts || [])
+ .map((c) => ({ id: c.id, email: c.email, displayName: c.display_name ?? null })),
});
setShowNewPassword(false);
@@ -734,6 +746,9 @@ export const EventDetailsPage: React.FC = () => {
// promo_markdown automatically when mode != 'custom'.
promo_mode: editForm.promo_mode,
promo_markdown: editForm.promo_mode === 'custom' ? editForm.promo_markdown : null,
+ // Customer accounts (#354) — flat array of ids. Backend diffs
+ // against existing assignments in one transaction.
+ customer_account_ids: editForm.customer_accounts.map((c) => c.id),
};
// Only include fields that have defined values
@@ -1131,6 +1146,13 @@ export const EventDetailsPage: React.FC = () => {
)}
+ {/* Customer accounts (#354). Picker self-hides when the
+ customerPortal feature flag is off. */}
+ setEditForm((prev) => ({ ...prev, customer_accounts: next }))}
+ />
+
{t('events.expirationDate')}
diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts
index ebfe6cfe..5ae37cbc 100644
--- a/frontend/src/pages/admin/index.ts
+++ b/frontend/src/pages/admin/index.ts
@@ -12,5 +12,7 @@ export { CMSPage } from './CMSPage';
export { BackupManagement } from './BackupManagement';
export { EventFeedbackPage } from './EventFeedbackPage';
export { UserManagementPage } from './UserManagementPage';
+export { CustomerManagementPage } from './CustomerManagementPage';
+export { CustomerDetailPage } from './CustomerDetailPage';
export { EventTypesPage } from './EventTypesPage';
export { WebhookDeliveriesPage } from './WebhookDeliveriesPage';
\ No newline at end of file
diff --git a/frontend/src/pages/customer/CustomerAcceptInvitePage.tsx b/frontend/src/pages/customer/CustomerAcceptInvitePage.tsx
new file mode 100644
index 00000000..8deca301
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerAcceptInvitePage.tsx
@@ -0,0 +1,501 @@
+/**
+ * Customer accept-invite page (#354).
+ *
+ * Mounted at /customer/invite/:token. Public route — anyone with the link
+ * can complete the invitation. The token IS the auth: 256 bits of entropy,
+ * single-use, 7-day TTL, server-side validated.
+ *
+ * Now collects the full profile (#354 follow-up):
+ * - admin can pre-fill any subset on /admin/customers invite, those
+ * values appear pre-populated and editable here
+ * - customer can correct or fill in anything else (phone, billing
+ * address, company)
+ * - password is the only required field besides the display name
+ *
+ * The profile fields are optional — a customer who just wants to log in
+ * fast can leave them blank and edit later from /customer/profile.
+ */
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { Lock, MapPin, Phone, User as UserIcon, AlertCircle, CheckCircle } from 'lucide-react';
+import { toast } from 'react-toastify';
+import { useTranslation } from 'react-i18next';
+
+import { Button, Input, Card, Loading } from '../../components/common';
+import {
+ customerService,
+ type CustomerInvitationInfo,
+ type CustomerProfilePrefill,
+} from '../../services/customer.service';
+import { usePublicSettings } from '../../hooks/usePublicSettings';
+
+interface FormState {
+ display_name: string;
+ password: string;
+ confirm: string;
+ salutation: string;
+ first_name: string;
+ last_name: string;
+ phone: string;
+ company_name: string;
+ vat_id: string;
+ address_line1: string;
+ address_line2: string;
+ postal_code: string;
+ city: string;
+ state: string;
+ country_code: string;
+}
+
+const SALUTATION_OPTIONS = [
+ { value: '', labelKey: 'customer.profile.salutation.none', fallback: '— Not specified —' },
+ { value: 'Herr', labelKey: 'customer.profile.salutation.herr', fallback: 'Herr' },
+ { value: 'Frau', labelKey: 'customer.profile.salutation.frau', fallback: 'Frau' },
+ { value: 'Mx', labelKey: 'customer.profile.salutation.mx', fallback: 'Mx' },
+ { value: 'Dr', labelKey: 'customer.profile.salutation.dr', fallback: 'Dr.' },
+];
+
+const EMPTY: FormState = {
+ display_name: '', password: '', confirm: '',
+ salutation: '', first_name: '', last_name: '',
+ phone: '', company_name: '', vat_id: '',
+ address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
+};
+
+export const CustomerAcceptInvitePage: React.FC = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { token = '' } = useParams<{ token: string }>();
+
+ const [invitation, setInvitation] = useState(null);
+ const [lookupError, setLookupError] = useState(null);
+ const [isLookingUp, setIsLookingUp] = useState(true);
+
+ const [form, setForm] = useState(EMPTY);
+ const [errors, setErrors] = useState>({});
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { data: settingsData } = usePublicSettings();
+ const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
+ const logoUrl = settingsData?.branding_logo_url?.trim();
+ const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
+
+ // Pre-flight invitation lookup. The response carries any prefill data
+ // the admin attached on /admin/customers invite — populate the form
+ // with that so the customer doesn't retype what their photographer
+ // already knows.
+ useEffect(() => {
+ let cancelled = false;
+ setIsLookingUp(true);
+ customerService.getInvitation(token)
+ .then((info) => {
+ if (cancelled) return;
+ setInvitation(info);
+ if (info.prefill) {
+ setForm((prev) => ({ ...prev, ...mergePrefillIntoForm(prev, info.prefill!) }));
+ }
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setLookupError(t(
+ 'customer.acceptInvite.invalidToken',
+ 'This invitation link is invalid or has expired. Please contact your photographer for a new invitation.'
+ ));
+ })
+ .finally(() => {
+ if (!cancelled) setIsLookingUp(false);
+ });
+ return () => { cancelled = true; };
+ }, [token, t]);
+
+ /**
+ * The display_name shown in the form is admin-prefilled if available,
+ * otherwise constructed from first/last name so the customer sees
+ * something sensible without us silently changing what they type.
+ */
+ const initialDisplayName = useMemo(() => {
+ if (form.display_name) return form.display_name;
+ const fromParts = [form.first_name, form.last_name].filter(Boolean).join(' ').trim();
+ return fromParts || '';
+ }, [form.display_name, form.first_name, form.last_name]);
+
+ const update = (key: keyof FormState, value: string) => {
+ setForm((p) => ({ ...p, [key]: value }));
+ };
+
+ const validate = (): boolean => {
+ const next: Record = {};
+ if (!initialDisplayName.trim()) {
+ next.display_name = t('customer.acceptInvite.nameRequired', 'Please enter your name');
+ }
+ if (form.password.length < 8) {
+ next.password = t('customer.acceptInvite.passwordTooShort', 'Password must be at least 8 characters');
+ }
+ if (form.password !== form.confirm) {
+ next.confirm = t('customer.acceptInvite.passwordsMismatch', 'Passwords do not match');
+ }
+ setErrors(next);
+ return Object.keys(next).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!validate()) return;
+ setIsSubmitting(true);
+ try {
+ const profile: CustomerProfilePrefill = {
+ salutation: form.salutation || undefined,
+ first_name: form.first_name || undefined,
+ last_name: form.last_name || undefined,
+ display_name: form.display_name || undefined,
+ phone: form.phone || undefined,
+ company_name: form.company_name || undefined,
+ vat_id: form.vat_id || undefined,
+ address_line1: form.address_line1 || undefined,
+ address_line2: form.address_line2 || undefined,
+ postal_code: form.postal_code || undefined,
+ city: form.city || undefined,
+ state: form.state || undefined,
+ country_code: form.country_code || undefined,
+ };
+ await customerService.acceptInvitation(token, initialDisplayName.trim(), form.password, profile);
+ toast.success(t('customer.acceptInvite.successToast', 'Account created — please log in.'));
+ navigate('/customer/login?accepted=1', { replace: true });
+ } catch (error: any) {
+ if (error.response?.status === 409) {
+ setErrors({ form: t('customer.acceptInvite.alreadyExists', 'An account with this email already exists. Please log in instead.') });
+ } else if (error.response?.data?.details?.length) {
+ setErrors({ password: error.response.data.details.join(' ') });
+ } else if (error.response?.status === 400) {
+ setErrors({ form: error.response?.data?.error || t('customer.acceptInvite.invalidSubmission', 'Could not create your account.') });
+ } else {
+ toast.error(t('customer.acceptInvite.generalError', 'Could not create your account. Please try again.'));
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t('customer.acceptInvite.title', 'Set up your account')}
+
+
+ {t('customer.acceptInvite.subtitle', 'Confirm or fill in your details. You can edit anything from the profile page later.')}
+
+
+
+
+ {isLookingUp ? (
+
+ ) : lookupError || !invitation ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+/**
+ * Translate the snake_case prefill payload (matches the backend wire shape)
+ * into the camelCase-ish form fields the local state uses. Kept inline
+ * because it's only ever called once on mount.
+ */
+function mergePrefillIntoForm(current: FormState, prefill: CustomerProfilePrefill): Partial {
+ const out: Partial = {};
+ if (prefill.salutation && !current.salutation) out.salutation = prefill.salutation;
+ if (prefill.first_name && !current.first_name) out.first_name = prefill.first_name;
+ if (prefill.last_name && !current.last_name) out.last_name = prefill.last_name;
+ if (prefill.display_name && !current.display_name) out.display_name = prefill.display_name;
+ if (prefill.phone && !current.phone) out.phone = prefill.phone;
+ if (prefill.company_name && !current.company_name) out.company_name = prefill.company_name;
+ if (prefill.vat_id && !current.vat_id) out.vat_id = prefill.vat_id;
+ if (prefill.address_line1 && !current.address_line1) out.address_line1 = prefill.address_line1;
+ if (prefill.address_line2 && !current.address_line2) out.address_line2 = prefill.address_line2;
+ if (prefill.postal_code && !current.postal_code) out.postal_code = prefill.postal_code;
+ if (prefill.city && !current.city) out.city = prefill.city;
+ if (prefill.state && !current.state) out.state = prefill.state;
+ if (prefill.country_code && !current.country_code) out.country_code = prefill.country_code;
+ return out;
+}
+
+export default CustomerAcceptInvitePage;
diff --git a/frontend/src/pages/customer/CustomerBillsPage.tsx b/frontend/src/pages/customer/CustomerBillsPage.tsx
new file mode 100644
index 00000000..ebcd0413
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerBillsPage.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Receipt } from 'lucide-react';
+import { CustomerComingSoonPage } from './CustomerComingSoonPage';
+
+export const CustomerBillsPage: React.FC = () => (
+
+);
+
+export default CustomerBillsPage;
diff --git a/frontend/src/pages/customer/CustomerCalendarPage.tsx b/frontend/src/pages/customer/CustomerCalendarPage.tsx
new file mode 100644
index 00000000..28eeee2c
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerCalendarPage.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { Calendar } from 'lucide-react';
+import { CustomerComingSoonPage } from './CustomerComingSoonPage';
+
+export const CustomerCalendarPage: React.FC = () => (
+
+);
+
+export default CustomerCalendarPage;
diff --git a/frontend/src/pages/customer/CustomerComingSoonPage.tsx b/frontend/src/pages/customer/CustomerComingSoonPage.tsx
new file mode 100644
index 00000000..a29e641f
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerComingSoonPage.tsx
@@ -0,0 +1,60 @@
+/**
+ * Generic placeholder for customer-surface features that aren't built yet
+ * but are surfaced in the sidebar so the maintainer can demo the layout
+ * without the feature being live (Calendar, Quotes, Bills — #354 follow-ups).
+ *
+ * Single component re-used for all three; the calling page passes the title
+ * + lucide icon so each route stays distinguishable in the address bar and
+ * heading.
+ */
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+interface CustomerComingSoonPageProps {
+ titleKey: string;
+ titleFallback: string;
+ bodyKey: string;
+ bodyFallback: string;
+ icon: React.ComponentType<{ className?: string }>;
+}
+
+export const CustomerComingSoonPage: React.FC = ({
+ titleKey, titleFallback, bodyKey, bodyFallback, icon: Icon,
+}) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+
+
+
+ {t('customer.comingSoon.tag', 'Coming soon')}
+
+
+ {t(titleKey, titleFallback)}
+
+
+ {t(bodyKey, bodyFallback)}
+
+
+
+ );
+};
+
+export default CustomerComingSoonPage;
diff --git a/frontend/src/pages/customer/CustomerDashboardPage.tsx b/frontend/src/pages/customer/CustomerDashboardPage.tsx
new file mode 100644
index 00000000..e9e5c152
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerDashboardPage.tsx
@@ -0,0 +1,304 @@
+/**
+ * Customer dashboard (#354) — list of every gallery the admin has granted
+ * this customer access to. Mounted at /customer/dashboard.
+ *
+ * Now a layout-wrapped page (Outlet child of CustomerLayout) — no inner
+ * wrapper.
+ *
+ * Design changes (#354 follow-up):
+ * - inline list rows instead of card grid (the maintainer asked for a
+ * denser, more spreadsheet-like view — works better when a customer
+ * has many recurring weddings)
+ * - sort dropdown: Name / Newest first / Oldest first
+ * - per-row Open + Download buttons (download bypasses the gallery and
+ * bundles a zip in one click)
+ *
+ * Card click → exchange the customer JWT for a per-event gallery JWT via
+ * /api/customer/events/:slug/access-token, the backend writes the
+ * gallery_token_ cookie alongside the JSON response, then we navigate
+ * to /gallery/:slug. The gallery code path needs no changes — it sees a
+ * regular gallery token exactly as if the per-event password had been
+ * entered.
+ */
+import React, { useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Calendar, Clock, Download, ExternalLink, ImageIcon, AlertCircle } from 'lucide-react';
+import { toast } from 'react-toastify';
+import { useTranslation } from 'react-i18next';
+import { format, parseISO } from 'date-fns';
+import { useQuery } from '@tanstack/react-query';
+
+import { Button, Loading } from '../../components/common';
+import { customerService, type CustomerEvent } from '../../services/customer.service';
+import { galleryService } from '../../services/gallery.service';
+import { storeGalleryToken, setActiveGallerySlug } from '../../utils/galleryAuthStorage';
+
+type SortKey = 'newest' | 'oldest' | 'name';
+
+const SORT_OPTIONS: Array<{ value: SortKey; labelKey: string; fallback: string }> = [
+ { value: 'newest', labelKey: 'customer.dashboard.sortNewest', fallback: 'Newest first' },
+ { value: 'oldest', labelKey: 'customer.dashboard.sortOldest', fallback: 'Oldest first' },
+ { value: 'name', labelKey: 'customer.dashboard.sortName', fallback: 'By name' },
+];
+
+/**
+ * Default to newest-first because that's almost always what a returning
+ * customer wants ("which gallery did they upload yesterday?"). The other
+ * orderings are mostly useful for archival browsing.
+ */
+const DEFAULT_SORT: SortKey = 'newest';
+
+export const CustomerDashboardPage: React.FC = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+
+ const { data: events, isLoading, error } = useQuery({
+ queryKey: ['customer-events'],
+ queryFn: () => customerService.listEvents(),
+ });
+
+ const [openingSlug, setOpeningSlug] = useState(null);
+ const [downloadingSlug, setDownloadingSlug] = useState(null);
+ const [sort, setSort] = useState(DEFAULT_SORT);
+
+ const sortedEvents = useMemo(() => {
+ const list: CustomerEvent[] = (events || []).slice();
+ // Use eventDate (the wedding/shoot date) as the primary key for date
+ // sorts; fall back to assignedAt when the event has no date set so
+ // entries don't all collapse to the bottom. Name sort is a basic
+ // case-insensitive locale compare.
+ const dateOf = (e: CustomerEvent) => (e.eventDate || e.assignedAt || '');
+ if (sort === 'newest') {
+ list.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
+ } else if (sort === 'oldest') {
+ list.sort((a, b) => dateOf(a).localeCompare(dateOf(b)));
+ } else {
+ list.sort((a, b) => a.eventName.localeCompare(b.eventName, undefined, { sensitivity: 'base' }));
+ }
+ return list;
+ }, [events, sort]);
+
+ const openEvent = async (slug: string) => {
+ if (openingSlug) return;
+ setOpeningSlug(slug);
+ try {
+ const { token } = await customerService.getEventAccessToken(slug);
+ storeGalleryToken(slug, token);
+ setActiveGallerySlug(slug);
+ navigate(`/gallery/${encodeURIComponent(slug)}`);
+ } catch (e: any) {
+ const status = e?.response?.status;
+ if (status === 410) {
+ toast.error(t('customer.dashboard.eventExpired', 'This gallery has expired.'));
+ } else if (status === 403) {
+ toast.error(t('customer.dashboard.eventForbidden', 'You no longer have access to this gallery.'));
+ } else {
+ toast.error(t('customer.dashboard.openError', 'Could not open this gallery. Please try again.'));
+ }
+ } finally {
+ setOpeningSlug(null);
+ }
+ };
+
+ const quickDownload = async (slug: string, eventName: string) => {
+ if (downloadingSlug) return;
+ setDownloadingSlug(slug);
+ try {
+ const { token } = await customerService.getEventAccessToken(slug);
+ storeGalleryToken(slug, token);
+ setActiveGallerySlug(slug);
+ await galleryService.downloadAllPhotos(slug, false);
+ toast.success(t('customer.dashboard.downloadStarted', 'Download started for {{name}}', { name: eventName }));
+ } catch (e: any) {
+ const status = e?.response?.status;
+ if (status === 410) {
+ toast.error(t('customer.dashboard.eventExpired', 'This gallery has expired.'));
+ } else if (status === 403) {
+ toast.error(t('customer.dashboard.eventForbidden', 'You no longer have access to this gallery.'));
+ } else {
+ toast.error(t('customer.dashboard.downloadError', 'Could not start the download. Please try again.'));
+ }
+ } finally {
+ setDownloadingSlug(null);
+ }
+ };
+
+ const formatDate = (iso: string | null) => {
+ if (!iso) return null;
+ try { return format(parseISO(iso), 'PP'); } catch { return null; }
+ };
+
+ return (
+
+
+
+
+ {t('customer.dashboard.title', 'Your galleries')}
+
+
+ {t('customer.dashboard.subtitle', 'Click a gallery to open it. The Download button bundles every photo as a zip.')}
+
+
+
+ {/* Sort dropdown — only render when there's something to sort. */}
+ {(events?.length || 0) > 1 && (
+
+
+ {t('customer.dashboard.sortLabel', 'Sort by')}
+
+ setSort(e.target.value as SortKey)}
+ className="rounded-lg border px-3 h-9 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
+ style={{
+ backgroundColor: 'var(--color-surface)',
+ borderColor: 'var(--color-surface-border)',
+ color: 'var(--color-text)',
+ }}
+ >
+ {SORT_OPTIONS.map((opt) => (
+
+ {t(opt.labelKey, opt.fallback)}
+
+ ))}
+
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : error ? (
+
+
+
+ {t('customer.dashboard.loadError', 'Could not load your galleries. Please try again.')}
+
+
+ ) : (sortedEvents.length === 0) ? (
+
+
+
+
+ {t('customer.dashboard.emptyTitle', 'No galleries yet')}
+
+
+ {t(
+ 'customer.dashboard.emptyBody',
+ 'Once your photographer assigns you to a gallery, it will appear here.'
+ )}
+
+
+
+ ) : (
+ // Inline list — one row per gallery, no card grid. Hover affordance
+ // via the entire row acting as a button (Open) plus a separate
+ // Download icon button so click bubbling doesn't cross-trigger.
+
+
+ {sortedEvents.map((ev) => {
+ const date = formatDate(ev.eventDate);
+ const expires = formatDate(ev.expiresAt);
+ const isExpired = ev.expiresAt ? new Date(ev.expiresAt) < new Date() : false;
+ const isOpening = openingSlug === ev.slug;
+ const isDownloading = downloadingSlug === ev.slug;
+ const rowDisabled = isExpired || openingSlug !== null || downloadingSlug !== null;
+ return (
+
+
+
+ {ev.eventName}
+
+
+ {date && (
+
+
+ {date}
+
+ )}
+ {expires && (
+
+
+ {isExpired
+ ? t('customer.dashboard.expiredOn', 'Expired {{date}}', { date: expires })
+ : t('customer.dashboard.expiresOn', 'Expires {{date}}', { date: expires })}
+
+ )}
+ {isOpening && (
+
+ {t('customer.dashboard.opening', 'Opening…')}
+
+ )}
+
+
+
+
+ {!isExpired && (
+ quickDownload(ev.slug, ev.eventName)}
+ disabled={rowDisabled}
+ leftIcon={ }
+ aria-label={t('customer.dashboard.quickDownloadAria', 'Download all photos for {{name}}', { name: ev.eventName })}
+ >
+
+ {isDownloading
+ ? t('customer.dashboard.preparingDownload', 'Preparing…')
+ : t('customer.dashboard.download', 'Download')}
+
+
+ )}
+ openEvent(ev.slug)}
+ disabled={rowDisabled}
+ leftIcon={ }
+ aria-label={t('customer.dashboard.openAria', 'Open gallery {{name}}', { name: ev.eventName })}
+ >
+
+ {t('customer.dashboard.open', 'Open')}
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+};
+
+export default CustomerDashboardPage;
diff --git a/frontend/src/pages/customer/CustomerLayout.tsx b/frontend/src/pages/customer/CustomerLayout.tsx
new file mode 100644
index 00000000..3f26f5d9
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerLayout.tsx
@@ -0,0 +1,269 @@
+/**
+ * Customer surface shell (#354).
+ *
+ * Visually patterned after the admin layout (sidebar + top header + scrollable
+ * main) — the maintainer asked for parity with /admin/* so admins dogfooding
+ * the customer flow get a familiar structure. Differences from AdminLayout:
+ * - no AdminSidebar / RBAC permission gating; customers don't have roles
+ * - branding header (logo + company name) sits inside the sidebar so the
+ * customer surface looks like *their* photographer's site, not picpeak
+ * chrome
+ * - calendar / quotes / bills nav items are stubbed (coming-soon pages);
+ * they're shown to the user behind a small "Coming soon" tag because
+ * they're built but intentionally inert until the matching backends ship
+ *
+ * Renders as a layout route (Outlet pattern) so individual pages don't need
+ * to wrap their content in `` — same approach AdminLayout uses.
+ */
+import React, { useState } from 'react';
+import { Link, NavLink, Outlet, Navigate, useLocation } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import {
+ Calendar,
+ FileText,
+ Image as ImageIcon,
+ LogOut,
+ Menu,
+ Receipt,
+ User as UserIcon,
+ X,
+} from 'lucide-react';
+
+import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
+import { usePublicSettings } from '../../hooks/usePublicSettings';
+
+interface NavItem {
+ to: string;
+ labelKey: string;
+ fallback: string;
+ icon: React.ComponentType<{ className?: string }>;
+ /**
+ * Optional gate — entry only renders when the matching feature is
+ * effective for this customer (i.e. global toggle ON and per-customer
+ * flag ON, AND-combined server-side in /api/customer/auth/session).
+ * Galleries + Profile are always visible; Calendar/Quotes/Bills are
+ * gated.
+ */
+ feature?: 'calendar' | 'quotes' | 'bills';
+}
+
+const NAV: NavItem[] = [
+ { to: '/customer/dashboard', labelKey: 'customer.nav.galleries', fallback: 'Galleries', icon: ImageIcon },
+ { to: '/customer/calendar', labelKey: 'customer.nav.calendar', fallback: 'Calendar', icon: Calendar, feature: 'calendar' },
+ { to: '/customer/quotes', labelKey: 'customer.nav.quotes', fallback: 'Quotes', icon: FileText, feature: 'quotes' },
+ { to: '/customer/bills', labelKey: 'customer.nav.bills', fallback: 'Bills', icon: Receipt, feature: 'bills' },
+ { to: '/customer/profile', labelKey: 'customer.nav.profile', fallback: 'Profile', icon: UserIcon },
+];
+
+export const CustomerLayout: React.FC = () => {
+ const { t } = useTranslation();
+ const location = useLocation();
+ const { customer, features, branding, isAuthenticated, isLoading, logout } = useCustomerAuth();
+ const { data: settingsData } = usePublicSettings();
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
+ const logoUrl = settingsData?.branding_logo_url?.trim();
+ const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
+
+ // Filter out feature-gated entries the customer can't see. Galleries +
+ // Profile have no feature property, so they're always present.
+ const visibleNav = NAV.filter((item) => !item.feature || features[item.feature] === true);
+
+ // Branding visibility — admin can hide either piece independently. If
+ // both are hidden the brand link still exists (you can click it to
+ // reach /customer/dashboard) but renders empty space at zero height.
+ const showLogo = branding.showLogo;
+ const showCompanyName = branding.showCompanyName;
+
+ // Loading screen mirrors AdminLayout's so admin-as-customer dogfooding
+ // sees a familiar transition. Background uses the theme variable so a
+ // dark Branding palette doesn't flash white on first paint.
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!isAuthenticated) {
+ return ;
+ }
+
+ const greetingName = customer?.displayName
+ || customer?.firstName
+ || (customer?.email ? customer.email.split('@')[0] : '');
+
+ return (
+ components via CSS variables — admin uses tailwind's
+ // `dark:` modifier (toggled on ), but the customer surface
+ // uses theme tokens so we scope the override here.
+ className="customer-surface h-screen flex overflow-hidden"
+ style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
+ >
+ {/* Mobile backdrop */}
+ {sidebarOpen && (
+
setSidebarOpen(false)}
+ aria-hidden="true"
+ />
+ )}
+
+ {/* Sidebar */}
+
+
+ {/* Main column */}
+
+
+ setSidebarOpen(true)}
+ className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-theme"
+ aria-label={t('common.menu', 'Menu')}
+ >
+
+
+ {companyName}
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CustomerLayout;
diff --git a/frontend/src/pages/customer/CustomerLoginPage.tsx b/frontend/src/pages/customer/CustomerLoginPage.tsx
new file mode 100644
index 00000000..c61a6e4e
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerLoginPage.tsx
@@ -0,0 +1,258 @@
+/**
+ * Customer login page (#354).
+ *
+ * Mounted at /customer/login. Strictly separate from /admin/login —
+ * different auth context, different cookie, different backend route.
+ */
+import React, { useState } from 'react';
+import { Navigate, useSearchParams } from 'react-router-dom';
+import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
+import { toast } from 'react-toastify';
+import { useTranslation } from 'react-i18next';
+
+import { Button, Input, Card, ReCaptcha } from '../../components/common';
+import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
+import { customerService } from '../../services/customer.service';
+import { usePublicSettings } from '../../hooks/usePublicSettings';
+
+export const CustomerLoginPage: React.FC = () => {
+ const { t } = useTranslation();
+ const { isAuthenticated, setSession } = useCustomerAuth();
+ const [searchParams] = useSearchParams();
+
+ const [formData, setFormData] = useState({ email: '', password: '' });
+ const [showPassword, setShowPassword] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const [errors, setErrors] = useState
>({});
+ const [recaptchaToken, setRecaptchaToken] = useState(null);
+
+ const { data: settingsData } = usePublicSettings();
+ const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
+ const logoUrl = settingsData?.branding_logo_url?.trim();
+ const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
+
+ // After /accept-invite the user is redirected here with ?accepted=1
+ // so we can show a friendly success toast on first paint.
+ React.useEffect(() => {
+ if (searchParams.get('accepted') === '1') {
+ toast.success(t('customer.login.acceptedToast', 'Account ready — please log in.'));
+ }
+ }, [searchParams, t]);
+
+ if (isAuthenticated) {
+ return ;
+ }
+
+ const validateForm = (): boolean => {
+ const next: Record = {};
+ if (!formData.email) {
+ next.email = t('customer.login.emailRequired', 'Email is required');
+ } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
+ next.email = t('customer.login.invalidEmail', 'Please enter a valid email');
+ }
+ if (!formData.password) {
+ next.password = t('customer.login.passwordRequired', 'Password is required');
+ }
+ setErrors(next);
+ return Object.keys(next).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ toast.dismiss();
+ if (!validateForm()) return;
+
+ setIsLoading(true);
+ setErrors({});
+ try {
+ const response = await customerService.login(
+ formData.email,
+ formData.password,
+ recaptchaToken
+ );
+ // Apply the full session payload (customer + features + branding)
+ // so the dashboard's first paint shows the correct sidebar. Using
+ // setCustomer alone left features at DEFAULT_FEATURES (all false)
+ // until the next CustomerAuthProvider remount, which is why the
+ // Soon menus only appeared after navigating to a gallery and back.
+ setSession(response);
+ toast.success(t('customer.login.loginSuccess', 'Welcome back!'));
+ // Navigate via Navigate component on next render — setCustomer
+ // flips isAuthenticated true so the redirect at the top fires.
+ } catch (error: any) {
+ if (error.response?.status === 429 || error.response?.status === 423) {
+ toast.error(t('customer.login.tooManyAttempts', 'Too many attempts — please try again later.'));
+ } else if (error.response?.status === 401) {
+ setErrors({ form: t('customer.login.invalidCredentials', 'Invalid email or password') });
+ } else if (error.code === 'ERR_NETWORK') {
+ toast.error(t('customer.login.networkError', 'Could not reach the server. Please try again.'));
+ } else {
+ toast.error(t('customer.login.generalError', 'Login failed. Please try again.'));
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleInputChange = (field: 'email' | 'password') =>
+ (e: React.ChangeEvent) => {
+ setFormData((prev) => ({ ...prev, [field]: e.target.value }));
+ if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' }));
+ };
+
+ return (
+ /
for dark
+ // backgrounds (admin uses the dark: trigger; customer uses theme
+ // tokens).
+ className="customer-surface min-h-screen flex items-center justify-center p-4"
+ style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
+ >
+
+ {/* Logo / header — matches AdminLoginPage's tinted square frame
+ so the brand presentation is identical across admin and
+ customer entry points. The frame itself is admin-controllable
+ via Branding → "Show tinted frame behind login logo" — same
+ toggle drives both login pages. */}
+
+ {settingsData?.branding_login_logo_frame_enabled !== false ? (
+
+
+
+ ) : (
+
+ )}
+
+ {t('customer.login.title', 'Customer login')}
+
+
+ {t('customer.login.subtitle', 'Access all of your photo galleries in one place.')}
+
+
+
+
+
+
+
+ {/* Footer — mirrors AdminLoginPage. Support email links to
+ mailto: with the address from Branding settings; falls back
+ to a placeholder so the link is never broken. The
+ "admin hint" line that used to live here is gone — admins
+ who land here on purpose can navigate to /admin/login on
+ their own. */}
+
+
+
+ );
+};
+
+export default CustomerLoginPage;
diff --git a/frontend/src/pages/customer/CustomerProfilePage.tsx b/frontend/src/pages/customer/CustomerProfilePage.tsx
new file mode 100644
index 00000000..4f98f58f
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerProfilePage.tsx
@@ -0,0 +1,536 @@
+/**
+ * Customer self-service profile (#354 follow-up).
+ *
+ * Mounted at /customer/profile. Lets the logged-in customer edit:
+ * - personal name (salutation / first / last / display)
+ * - contact (phone, company, VAT id)
+ * - billing address
+ * - password
+ *
+ * The layout intentionally mirrors the admin detail pages (sectioned
+ * Cards, two-column form on wide screens, save buttons inside each section
+ * so a customer who only wants to fix their phone number doesn't have to
+ * scroll past the address). Email is read-only here — changing the login
+ * credential is admin-only for the same reason it is on AdminUserDetail.
+ *
+ * Two endpoints are hit:
+ * - PUT /api/customer/profile (name + contact + address)
+ * - POST /api/customer/profile/password (password change with re-auth)
+ *
+ * The password section bumps password_changed_at on the server, which
+ * silently logs other browser sessions out of this customer account on
+ * their next request. The current session keeps its cookie so the user
+ * doesn't get bounced to login mid-flow.
+ */
+import React, { useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'react-toastify';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { Lock, Save, User as UserIcon, MapPin, Phone, Mail } from 'lucide-react';
+
+import { Button, Input, Loading } from '../../components/common';
+
+/**
+ * Inline tile wrapper used in place of on this page.
+ *
+ * The global .card class (used by ) hard-codes bg-white + neutral
+ * borders, which fights the dark theme on the customer surface (the same
+ * fix the dashboard already shipped). Pinning to the theme tokens here
+ * keeps every section card consistent with the sidebar and the
+ * dashboard.
+ */
+const ProfileTile: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+
+ {children}
+
+);
+import {
+ customerService,
+ type CustomerProfileFull,
+ type CustomerProfileUpdate,
+} from '../../services/customer.service';
+
+const SALUTATION_OPTIONS = [
+ { value: '', labelKey: 'customer.profile.salutation.none', fallback: '— Not specified —' },
+ { value: 'Herr', labelKey: 'customer.profile.salutation.herr', fallback: 'Herr' },
+ { value: 'Frau', labelKey: 'customer.profile.salutation.frau', fallback: 'Frau' },
+ { value: 'Mx', labelKey: 'customer.profile.salutation.mx', fallback: 'Mx' },
+ { value: 'Dr', labelKey: 'customer.profile.salutation.dr', fallback: 'Dr.' },
+];
+
+/** Normalise a server profile into the local form-state shape (string | ''). */
+function profileToForm(p: CustomerProfileFull): CustomerProfileUpdate {
+ return {
+ salutation: p.salutation ?? '',
+ firstName: p.firstName ?? '',
+ lastName: p.lastName ?? '',
+ displayName: p.displayName ?? '',
+ phone: p.phone ?? '',
+ companyName: p.companyName ?? '',
+ vatId: p.vatId ?? '',
+ addressLine1: p.addressLine1 ?? '',
+ addressLine2: p.addressLine2 ?? '',
+ postalCode: p.postalCode ?? '',
+ city: p.city ?? '',
+ state: p.state ?? '',
+ countryCode: p.countryCode ?? '',
+ preferredLanguage: p.preferredLanguage ?? 'en',
+ };
+}
+
+export const CustomerProfilePage: React.FC = () => {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+
+ const { data: profile, isLoading, error } = useQuery({
+ queryKey: ['customer-profile'],
+ queryFn: () => customerService.getProfile(),
+ });
+
+ // Local form state — initialised from server profile, edited freely until
+ // the user clicks Save. We keep it as a single object to make the diffing
+ // for the PUT call straightforward.
+ const [form, setForm] = useState({});
+ const [savingProfile, setSavingProfile] = useState(false);
+ const [profileErr, setProfileErr] = useState(null);
+
+ // Password change is its own mini-form. Kept separate so the main save
+ // doesn't accidentally sweep up half-typed password fields.
+ const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
+ const [pwErrors, setPwErrors] = useState>({});
+ const [savingPassword, setSavingPassword] = useState(false);
+
+ useEffect(() => {
+ if (profile) setForm(profileToForm(profile));
+ }, [profile]);
+
+ const updateField = (key: keyof CustomerProfileUpdate, value: string) => {
+ setForm((p) => ({ ...p, [key]: value }));
+ };
+
+ const handleProfileSave = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setProfileErr(null);
+ setSavingProfile(true);
+ try {
+ // Send empty strings as null so the server clears the row instead of
+ // storing whitespace. The backend already coerces empty strings, but
+ // doing it client-side keeps the request payload honest.
+ const payload: CustomerProfileUpdate = {};
+ for (const [k, v] of Object.entries(form)) {
+ const key = k as keyof CustomerProfileUpdate;
+ payload[key] = (typeof v === 'string' && v.trim() === '') ? null : v as any;
+ }
+ const updated = await customerService.updateProfile(payload);
+ setForm(profileToForm(updated));
+ qc.invalidateQueries({ queryKey: ['customer-profile'] });
+ toast.success(t('customer.profile.savedToast', 'Profile saved'));
+ } catch (err: any) {
+ setProfileErr(err?.response?.data?.error || t('customer.profile.saveError', 'Could not save profile.'));
+ } finally {
+ setSavingProfile(false);
+ }
+ };
+
+ const validatePassword = (): boolean => {
+ const next: Record = {};
+ if (!pwForm.current) {
+ next.current = t('customer.profile.password.currentRequired', 'Enter your current password');
+ }
+ if (pwForm.next.length < 8) {
+ next.next = t('customer.profile.password.tooShort', 'At least 8 characters');
+ }
+ if (pwForm.next !== pwForm.confirm) {
+ next.confirm = t('customer.profile.password.mismatch', 'Passwords do not match');
+ }
+ setPwErrors(next);
+ return Object.keys(next).length === 0;
+ };
+
+ const handlePasswordSave = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!validatePassword()) return;
+ setSavingPassword(true);
+ try {
+ await customerService.changePassword(pwForm.current, pwForm.next);
+ setPwForm({ current: '', next: '', confirm: '' });
+ setPwErrors({});
+ toast.success(t('customer.profile.password.savedToast', 'Password updated'));
+ } catch (err: any) {
+ const status = err?.response?.status;
+ if (status === 401) {
+ setPwErrors({ current: t('customer.profile.password.wrong', 'Current password is incorrect') });
+ } else if (status === 400 && err?.response?.data?.details?.length) {
+ setPwErrors({ next: err.response.data.details.join(' ') });
+ } else {
+ toast.error(t('customer.profile.password.error', 'Could not change password'));
+ }
+ } finally {
+ setSavingPassword(false);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !profile) {
+ return (
+
+
+
+ {t('customer.profile.loadError', 'Could not load your profile.')}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t('customer.profile.title', 'Customer profile')}
+
+
+ {t('customer.profile.subtitle', 'Keep your contact and billing details up to date — they\'re shown on quotes and invoices once those features go live.')}
+
+
+
+ {/* Personal info + contact + address — single combined form so a
+ customer can update everything in one save. The visual sections
+ are inside the form purely for grouping. */}
+
+
+ {/* Password change — separate form so it doesn't fight with the main
+ save button and the user's password autofill never leaks into
+ unrelated fields. */}
+
+
+
+
+ {t('customer.profile.section.password', 'Change password')}
+
+
+
+
+
+
+ );
+};
+
+export default CustomerProfilePage;
diff --git a/frontend/src/pages/customer/CustomerQuotesPage.tsx b/frontend/src/pages/customer/CustomerQuotesPage.tsx
new file mode 100644
index 00000000..2563a3b9
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerQuotesPage.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import { FileText } from 'lucide-react';
+import { CustomerComingSoonPage } from './CustomerComingSoonPage';
+
+export const CustomerQuotesPage: React.FC = () => (
+
+);
+
+export default CustomerQuotesPage;
diff --git a/frontend/src/pages/customer/CustomerResetPasswordPage.tsx b/frontend/src/pages/customer/CustomerResetPasswordPage.tsx
new file mode 100644
index 00000000..f003b59a
--- /dev/null
+++ b/frontend/src/pages/customer/CustomerResetPasswordPage.tsx
@@ -0,0 +1,182 @@
+/**
+ * Customer password reset (#354 follow-up).
+ *
+ * Mounted at /customer/reset-password/:token. Public route — anyone with
+ * the link can complete the reset. The token IS the auth: 256 bits of
+ * entropy, single-use, 7-day TTL, server-side validated; the existing
+ * password keeps working until this page successfully POSTs a new one.
+ *
+ * Mirrors CustomerAcceptInvitePage's chrome (logo + branded background)
+ * for visual consistency with the rest of the customer surface.
+ */
+import React, { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { Lock, AlertCircle, CheckCircle } from 'lucide-react';
+import { toast } from 'react-toastify';
+import { useTranslation } from 'react-i18next';
+
+import { Button, Input, Card, Loading } from '../../components/common';
+import { customerService } from '../../services/customer.service';
+import { usePublicSettings } from '../../hooks/usePublicSettings';
+
+export const CustomerResetPasswordPage: React.FC = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { token = '' } = useParams<{ token: string }>();
+
+ const [reset, setReset] = useState<{ email: string; expiresAt: string } | null>(null);
+ const [lookupError, setLookupError] = useState(null);
+ const [isLookingUp, setIsLookingUp] = useState(true);
+
+ const [form, setForm] = useState({ password: '', confirm: '' });
+ const [errors, setErrors] = useState>({});
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { data: settingsData } = usePublicSettings();
+ const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
+ const logoUrl = settingsData?.branding_logo_url?.trim();
+ const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
+
+ // Pre-flight token validation. Same pattern as the invite page — if the
+ // token is invalid we render an error state instead of a useless form.
+ useEffect(() => {
+ let cancelled = false;
+ setIsLookingUp(true);
+ customerService.getPasswordReset(token)
+ .then((info) => {
+ if (cancelled) return;
+ setReset(info);
+ })
+ .catch(() => {
+ if (cancelled) return;
+ setLookupError(t(
+ 'customer.resetPassword.invalidToken',
+ 'This reset link is invalid or has expired. Please ask your photographer to send a new one.'
+ ));
+ })
+ .finally(() => {
+ if (!cancelled) setIsLookingUp(false);
+ });
+ return () => { cancelled = true; };
+ }, [token, t]);
+
+ const validate = (): boolean => {
+ const next: Record = {};
+ if (form.password.length < 8) {
+ next.password = t('customer.resetPassword.tooShort', 'Password must be at least 8 characters');
+ }
+ if (form.password !== form.confirm) {
+ next.confirm = t('customer.resetPassword.mismatch', 'Passwords do not match');
+ }
+ setErrors(next);
+ return Object.keys(next).length === 0;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!validate()) return;
+ setIsSubmitting(true);
+ try {
+ await customerService.applyPasswordReset(token, form.password);
+ toast.success(t('customer.resetPassword.successToast', 'Password updated. Please log in.'));
+ navigate('/customer/login?reset=1', { replace: true });
+ } catch (error: any) {
+ if (error.response?.data?.details?.length) {
+ setErrors({ password: error.response.data.details.join(' ') });
+ } else if (error.response?.status === 400) {
+ setErrors({ form: error.response?.data?.error || t('customer.resetPassword.invalidSubmission', 'Could not update your password.') });
+ } else {
+ toast.error(t('customer.resetPassword.generalError', 'Could not update your password. Please try again.'));
+ }
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t('customer.resetPassword.title', 'Reset your password')}
+
+
+
+
+ {isLookingUp ? (
+
+ ) : lookupError || !reset ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export default CustomerResetPasswordPage;
diff --git a/frontend/src/pages/customer/index.ts b/frontend/src/pages/customer/index.ts
new file mode 100644
index 00000000..acc6406b
--- /dev/null
+++ b/frontend/src/pages/customer/index.ts
@@ -0,0 +1,9 @@
+export { CustomerLoginPage } from './CustomerLoginPage';
+export { CustomerDashboardPage } from './CustomerDashboardPage';
+export { CustomerAcceptInvitePage } from './CustomerAcceptInvitePage';
+export { CustomerLayout } from './CustomerLayout';
+export { CustomerProfilePage } from './CustomerProfilePage';
+export { CustomerCalendarPage } from './CustomerCalendarPage';
+export { CustomerQuotesPage } from './CustomerQuotesPage';
+export { CustomerBillsPage } from './CustomerBillsPage';
+export { CustomerResetPasswordPage } from './CustomerResetPasswordPage';
diff --git a/frontend/src/services/customer.service.ts b/frontend/src/services/customer.service.ts
new file mode 100644
index 00000000..61a49184
--- /dev/null
+++ b/frontend/src/services/customer.service.ts
@@ -0,0 +1,224 @@
+/**
+ * Customer-side API client (#354).
+ *
+ * Strictly separate from authService.adminLogin / galleryService — uses
+ * the /api/customer/* surface and the customer_token cookie. Never falls
+ * back to admin endpoints.
+ */
+import { api } from '../config/api';
+
+export interface CustomerProfile {
+ id: number;
+ email: string;
+ displayName: string | null;
+ firstName: string | null;
+ lastName: string | null;
+ preferredLanguage: string;
+}
+
+/**
+ * Full self-service profile shape — superset of CustomerProfile (which is
+ * the narrow auth-payload version). Used by the profile page and the
+ * accept-invite form.
+ */
+export interface CustomerProfileFull extends CustomerProfile {
+ salutation: string | null;
+ phone: string | null;
+ companyName: string | null;
+ vatId: string | null;
+ addressLine1: string | null;
+ addressLine2: string | null;
+ postalCode: string | null;
+ city: string | null;
+ state: string | null;
+ countryCode: string | null;
+}
+
+/** Subset of profile fields the admin can pre-fill on an invitation
+ * and that the customer can edit on accept. */
+export interface CustomerProfilePrefill {
+ salutation?: string;
+ first_name?: string;
+ last_name?: string;
+ display_name?: string;
+ phone?: string;
+ company_name?: string;
+ vat_id?: string;
+ address_line1?: string;
+ address_line2?: string;
+ postal_code?: string;
+ city?: string;
+ state?: string;
+ country_code?: string;
+}
+
+export interface CustomerEvent {
+ id: number;
+ slug: string;
+ eventName: string;
+ eventType: string;
+ eventDate: string | null;
+ expiresAt: string | null;
+ isActive: boolean;
+ assignedAt: string;
+}
+
+export interface CustomerInvitationInfo {
+ email: string;
+ expiresAt: string;
+ invitedBy: string | null;
+ /** Admin-supplied prefill — populates the accept-invite profile form. */
+ prefill: CustomerProfilePrefill | null;
+}
+
+export interface CustomerProfileUpdate {
+ salutation?: string | null;
+ firstName?: string | null;
+ lastName?: string | null;
+ displayName?: string | null;
+ phone?: string | null;
+ companyName?: string | null;
+ vatId?: string | null;
+ addressLine1?: string | null;
+ addressLine2?: string | null;
+ postalCode?: string | null;
+ city?: string | null;
+ state?: string | null;
+ countryCode?: string | null;
+ preferredLanguage?: string;
+}
+
+export interface CustomerAccessTokenResponse {
+ token: string;
+ event: { id: number; slug: string; eventName: string };
+}
+
+export const customerService = {
+ // ---- auth ----
+ async login(email: string, password: string, recaptchaToken?: string | null): Promise<{
+ customer: CustomerProfile;
+ features: { calendar: boolean; quotes: boolean; bills: boolean };
+ branding: { showLogo: boolean; showCompanyName: boolean };
+ }> {
+ const response = await api.post<{
+ customer: CustomerProfile;
+ features?: { calendar: boolean; quotes: boolean; bills: boolean };
+ branding?: { showLogo: boolean; showCompanyName: boolean };
+ }>(
+ '/customer/auth/login',
+ { email, password, recaptchaToken }
+ );
+ // Backwards-compat fallbacks for older backends that haven't been
+ // upgraded yet — defaults match CustomerAuthContext's DEFAULT_*.
+ return {
+ customer: response.data.customer,
+ features: response.data.features || { calendar: false, quotes: false, bills: false },
+ branding: response.data.branding || { showLogo: true, showCompanyName: true },
+ };
+ },
+
+ async logout(): Promise {
+ try {
+ await api.post('/customer/auth/logout');
+ } catch (e) {
+ // Logout is best-effort — the cookie clear is what matters and
+ // the backend always clears it even on error.
+ }
+ },
+
+ async session(): Promise<{
+ customer: CustomerProfile;
+ features: { calendar: boolean; quotes: boolean; bills: boolean };
+ branding: { showLogo: boolean; showCompanyName: boolean };
+ } | null> {
+ try {
+ const response = await api.get<{
+ customer: CustomerProfile;
+ features?: { calendar: boolean; quotes: boolean; bills: boolean };
+ branding?: { showLogo: boolean; showCompanyName: boolean };
+ }>('/customer/auth/session');
+ return {
+ customer: response.data.customer,
+ features: response.data.features || { calendar: false, quotes: false, bills: false },
+ branding: response.data.branding || { showLogo: true, showCompanyName: true },
+ };
+ } catch {
+ return null;
+ }
+ },
+
+ /**
+ * Look up a password-reset token without consuming it. Lets the reset
+ * page render "you're resetting the password for {{email}}" before
+ * the customer submits.
+ */
+ async getPasswordReset(token: string): Promise<{ email: string; expiresAt: string }> {
+ const response = await api.get<{ reset: { email: string; expiresAt: string } }>(
+ `/customer/auth/password-reset/${encodeURIComponent(token)}`,
+ );
+ return response.data.reset;
+ },
+
+ /** Apply a password reset (token + new password). */
+ async applyPasswordReset(token: string, password: string): Promise<{ email: string }> {
+ const response = await api.post<{ email: string }>(
+ '/customer/auth/password-reset',
+ { token, password },
+ );
+ return response.data;
+ },
+
+ // ---- invitations ----
+ async getInvitation(token: string): Promise {
+ const response = await api.get<{ invitation: CustomerInvitationInfo }>(
+ `/customer/auth/invite/${encodeURIComponent(token)}`
+ );
+ return response.data.invitation;
+ },
+
+ async acceptInvitation(
+ token: string,
+ name: string,
+ password: string,
+ profile?: CustomerProfilePrefill,
+ ): Promise<{ email: string }> {
+ const response = await api.post<{ email: string }>(
+ '/customer/auth/accept-invite',
+ { token, name, password, profile },
+ );
+ return response.data;
+ },
+
+ // ---- profile (self-service) ----
+ async getProfile(): Promise {
+ const response = await api.get<{ profile: CustomerProfileFull }>('/customer/profile');
+ return response.data.profile;
+ },
+
+ async updateProfile(payload: CustomerProfileUpdate): Promise {
+ const response = await api.put<{ profile: CustomerProfileFull }>('/customer/profile', payload);
+ return response.data.profile;
+ },
+
+ async changePassword(currentPassword: string, newPassword: string): Promise {
+ await api.post('/customer/profile/password', { currentPassword, newPassword });
+ },
+
+ // ---- dashboard ----
+ async listEvents(): Promise {
+ const response = await api.get<{ events: CustomerEvent[] }>('/customer/events');
+ return response.data.events;
+ },
+
+ /**
+ * Exchange the customer JWT for a gallery JWT scoped to one event.
+ * The dashboard calls this on card-click and stores the resulting
+ * token in the slug-specific gallery cookie via storeGalleryToken().
+ */
+ async getEventAccessToken(slug: string): Promise {
+ const response = await api.get(
+ `/customer/events/${encodeURIComponent(slug)}/access-token`
+ );
+ return response.data;
+ },
+};
diff --git a/frontend/src/services/customerAdmin.service.ts b/frontend/src/services/customerAdmin.service.ts
new file mode 100644
index 00000000..eeca7fe9
--- /dev/null
+++ b/frontend/src/services/customerAdmin.service.ts
@@ -0,0 +1,188 @@
+/**
+ * Admin → Customers API client (#354).
+ *
+ * Hits /api/admin/customers/* (admin auth). Distinct from customer.service.ts
+ * which is the customer's own /api/customer/* surface.
+ */
+import { api } from '../config/api';
+
+export interface CustomerAccountSummary {
+ id: number;
+ email: string;
+ displayName: string | null;
+ firstName: string | null;
+ lastName: string | null;
+ salutation: string | null;
+ companyName: string | null;
+ isActive: boolean;
+ lastLogin: string | null;
+ createdAt: string;
+ eventCount?: number;
+ /** Per-customer feature flags (#354 follow-up). */
+ featureCalendar?: boolean;
+ featureQuotes?: boolean;
+ featureBills?: boolean;
+}
+
+export interface CustomerAccountDetail extends CustomerAccountSummary {
+ phone: string | null;
+ billingEmail: string | null;
+ vatId: string | null;
+ addressLine1: string | null;
+ addressLine2: string | null;
+ postalCode: string | null;
+ city: string | null;
+ state: string | null;
+ countryCode: string | null;
+ preferredLanguage: string;
+ notes: string | null;
+ events: Array<{
+ id: number;
+ slug: string;
+ eventName: string;
+ eventDate: string | null;
+ expiresAt: string | null;
+ isArchived: boolean;
+ assignedAt: string;
+ }>;
+}
+
+/** Optional admin-side prefill on invite — see /admin/customers/invite. */
+export interface CustomerInvitePrefill {
+ salutation?: string;
+ first_name?: string;
+ last_name?: string;
+ display_name?: string;
+ phone?: string;
+ company_name?: string;
+ vat_id?: string;
+ address_line1?: string;
+ address_line2?: string;
+ postal_code?: string;
+ city?: string;
+ state?: string;
+ country_code?: string;
+}
+
+export interface CustomerInvitationSummary {
+ id: number;
+ email: string;
+ expiresAt: string;
+ createdAt: string;
+ invitedBy: string | null;
+}
+
+export const customerAdminService = {
+ async list(search?: string): Promise {
+ const response = await api.get<{ customers: CustomerAccountSummary[] }>(
+ '/admin/customers',
+ { params: search ? { search } : undefined }
+ );
+ return response.data.customers;
+ },
+
+ async search(term: string): Promise {
+ if (!term || !term.trim()) return [];
+ const response = await api.get<{ customers: CustomerAccountSummary[] }>(
+ '/admin/customers/search',
+ { params: { email: term } }
+ );
+ return response.data.customers;
+ },
+
+ async get(id: number): Promise {
+ const response = await api.get<{ customer: CustomerAccountDetail }>(`/admin/customers/${id}`);
+ return response.data.customer;
+ },
+
+ async update(id: number, payload: Partial>): Promise {
+ // Frontend sends camelCase, backend accepts snake_case — translate here
+ // so callers can stay in TS-land conventions.
+ const snake: Record = {};
+ const map: Record = {
+ email: 'email',
+ salutation: 'salutation',
+ firstName: 'first_name',
+ lastName: 'last_name',
+ displayName: 'display_name',
+ phone: 'phone',
+ companyName: 'company_name',
+ billingEmail: 'billing_email',
+ vatId: 'vat_id',
+ addressLine1: 'address_line1',
+ addressLine2: 'address_line2',
+ postalCode: 'postal_code',
+ city: 'city',
+ state: 'state',
+ countryCode: 'country_code',
+ preferredLanguage: 'preferred_language',
+ notes: 'notes',
+ isActive: 'is_active',
+ // Per-customer feature flags (#354 follow-up).
+ featureCalendar: 'feature_calendar',
+ featureQuotes: 'feature_quotes',
+ featureBills: 'feature_bills',
+ };
+ for (const [k, v] of Object.entries(payload)) {
+ if (k in map) snake[map[k]] = v;
+ }
+ const response = await api.put<{ customer: CustomerAccountDetail }>(`/admin/customers/${id}`, snake);
+ return response.data.customer;
+ },
+
+ async deactivate(id: number): Promise {
+ await api.post(`/admin/customers/${id}/deactivate`);
+ },
+
+ /** Restore a deactivated customer (login re-enabled, assignments stay). */
+ async reactivate(id: number): Promise {
+ await api.post(`/admin/customers/${id}/reactivate`);
+ },
+
+ /**
+ * Anonymize-in-place erasure (GDPR style). Customer row stays for
+ * audit FKs but every PII column is nulled and credentials are wiped.
+ * See backend service `eraseCustomer` for the full contract.
+ */
+ async erase(id: number): Promise {
+ await api.post(`/admin/customers/${id}/erase`);
+ },
+
+ /**
+ * Trigger a password reset for an existing customer. The backend
+ * generates a 7-day single-use token and emails the customer.
+ */
+ async sendPasswordReset(id: number): Promise<{ email: string; expiresAt: string }> {
+ const response = await api.post<{ data: { email: string; expiresAt: string } } | { email: string; expiresAt: string }>(
+ `/admin/customers/${id}/password-reset`,
+ );
+ return ((response.data as any).data ?? response.data) as { email: string; expiresAt: string };
+ },
+
+ /**
+ * Invite a customer. `prefill` is an optional set of profile fields the
+ * admin can pre-populate on the invitation row — the customer sees them
+ * pre-filled (and editable) on the accept form. Saves the customer typing
+ * for the common case where the photographer already has the wedding
+ * couple's name + address from the booking form.
+ */
+ async invite(
+ email: string,
+ prefill?: CustomerInvitePrefill,
+ ): Promise<{ id: number; email: string; expiresAt: string }> {
+ const response = await api.post<{ data: { invitation: { id: number; email: string; expiresAt: string } } }>(
+ '/admin/customers/invite',
+ { email, prefill },
+ );
+ return (response.data as any).data?.invitation ?? (response.data as any).invitation;
+ },
+
+ async listInvitations(): Promise {
+ const response = await api.get<{ invitations: CustomerInvitationSummary[] }>('/admin/customers/invitations');
+ return response.data.invitations;
+ },
+
+ async cancelInvitation(id: number): Promise {
+ await api.delete(`/admin/customers/invitations/${id}`);
+ },
+};
diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts
index 26079194..61f134dd 100644
--- a/frontend/src/services/events.service.ts
+++ b/frontend/src/services/events.service.ts
@@ -41,6 +41,10 @@ interface CreateEventData {
show_feedback_to_guests?: boolean;
photo_cap?: number | null;
default_photo_sort?: string;
+ // Customer accounts assigned to this event (#354). Optional array of
+ // customer_accounts.id; backend service diffs against the existing
+ // assignments and applies inserts/deletes inside the same transaction.
+ customer_account_ids?: number[];
}
interface UpdateEventData {
@@ -62,6 +66,9 @@ interface UpdateEventData {
external_path?: string | null;
photo_cap?: number | null;
default_photo_sort?: string;
+ // Customer accounts (#354). Same semantics as on CreateEventData;
+ // omit the field to leave assignments untouched, send [] to clear.
+ customer_account_ids?: number[];
}
export type EventStatusFilter = 'active' | 'inactive' | 'archived' | 'draft' | 'expiring';
diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts
index 592686af..5aa692f7 100644
--- a/frontend/src/services/featureFlags.service.ts
+++ b/frontend/src/services/featureFlags.service.ts
@@ -9,7 +9,14 @@ export type FeatureKey =
| 'bills'
| 'messaging'
| 'analytics'
- | 'userManagement';
+ | 'userManagement'
+ // Foundation flag for the customer-side surface (#354). Gates the
+ // /customer/* routes (login, dashboard, profile, accept-invite,
+ // reset-password) and the admin Customers management page. The
+ // calendar / calendarBooking / quotes / bills / messaging flags
+ // above hang off this — they only appear in the customer dashboard
+ // when customerPortal is also ON.
+ | 'customerPortal';
export type FeatureFlags = Record;
diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts
index e9a1b6ab..4f4436fd 100644
--- a/frontend/src/services/index.ts
+++ b/frontend/src/services/index.ts
@@ -9,4 +9,5 @@ export { settingsService } from './settings.service';
export { cmsService } from './cms.service';
export { notificationsService } from './notifications.service';
export { feedbackService } from './feedback.service';
-export { userManagementService } from './userManagement.service';
\ No newline at end of file
+export { userManagementService } from './userManagement.service';
+export { customerService } from './customer.service';
\ No newline at end of file
diff --git a/tests/e2e/customer-portal-flow.spec.ts b/tests/e2e/customer-portal-flow.spec.ts
new file mode 100644
index 00000000..b50a3c96
--- /dev/null
+++ b/tests/e2e/customer-portal-flow.spec.ts
@@ -0,0 +1,254 @@
+/**
+ * Customer portal end-to-end flow (#354).
+ *
+ * Covers the maintainer-flagged "core promise" of the feature:
+ * a customer can log in once and open every assigned gallery without
+ * re-entering the per-event password. Specifically:
+ *
+ * 1. Admin enables the Customer dashboard (Settings → Advanced features).
+ * 2. Admin creates an event AND invites a customer to that event.
+ * 3. Customer accepts the invitation (sets a password).
+ * 4. Customer logs in.
+ * 5. Customer's dashboard lists the assigned gallery.
+ * 6. Customer clicks the gallery → lands on /gallery/ WITHOUT
+ * seeing a password prompt. The grid renders with photos.
+ *
+ * Side checks:
+ * - With the master toggle OFF, /customer/login redirects to /admin/login
+ * (frontend gate) and the customer-side API returns 410 Gone (backend
+ * gate). This is the kill-switch contract.
+ *
+ * Hits the API directly for setup (admin login, event create, photo upload,
+ * customer invite, accept-invite, gallery assignment) and only uses the
+ * browser for the parts that genuinely need the SPA: the gallery handoff
+ * itself, where the bug surface lives. Keeps the spec fast and avoids
+ * DOM-fragility on every admin form field.
+ */
+
+import { test, expect, Page } from '@playwright/test';
+import fs from 'fs';
+import path from 'path';
+
+const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
+const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
+const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'CustomerPortalGallery!1';
+const CUSTOMER_PASSWORD = 'CustomerPortalUser!1';
+
+async function adminLogin(page: Page): Promise {
+ const res = await page.request.post('/api/auth/admin/login', {
+ data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
+ failOnStatusCode: false,
+ });
+ expect(res.ok()).toBeTruthy();
+ const json = await res.json();
+ expect(json.token).toBeTruthy();
+ return json.token;
+}
+
+async function setCustomerPortalEnabled(page: Page, adminToken: string, enabled: boolean) {
+ const res = await page.request.put('/api/admin/settings/advanced-features', {
+ headers: {
+ Authorization: `Bearer ${adminToken}`,
+ 'Content-Type': 'application/json',
+ },
+ data: { customer_portal_enabled: enabled },
+ failOnStatusCode: false,
+ });
+ expect(res.ok()).toBeTruthy();
+}
+
+async function createEventWithPhoto(page: Page, adminToken: string) {
+ const eventName = `Customer Portal E2E ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
+
+ const createRes = await page.request.post('/api/admin/events', {
+ headers: {
+ Authorization: `Bearer ${adminToken}`,
+ 'Content-Type': 'application/json',
+ },
+ data: {
+ event_type: 'wedding',
+ event_name: eventName,
+ event_date: eventDate,
+ customer_name: 'Customer Portal Host',
+ customer_email: 'host@example.com',
+ admin_email: ADMIN_EMAIL,
+ password: GALLERY_PASSWORD,
+ expiration_days: 30,
+ allow_user_uploads: false,
+ allow_downloads: true,
+ },
+ failOnStatusCode: false,
+ });
+ if (!createRes.ok()) {
+ throw new Error(`Event create failed: ${createRes.status()} ${await createRes.text()}`);
+ }
+ const event = await createRes.json();
+
+ // One photo so the gallery grid has something to render after the
+ // customer hits it. Tests that the gallery loads, not that it's empty.
+ const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
+ const buffer = fs.readFileSync(imagePath);
+ const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ multipart: {
+ photos: { name: 'img1.png', mimeType: 'image/png', buffer },
+ category_id: 'individual',
+ },
+ failOnStatusCode: false,
+ });
+ expect(uploadRes.ok()).toBeTruthy();
+
+ return event;
+}
+
+/**
+ * Invite a customer, accept the invitation, return the email.
+ *
+ * The admin invite response echoes `invitation.token` only when
+ * NODE_ENV !== 'production' — that lets the spec skip the email
+ * round-trip without needing a separate /admin/email-queue endpoint
+ * or direct DB access. In production the token stays email-only.
+ */
+async function inviteAndAcceptCustomer(page: Page, adminToken: string) {
+ const email = `customer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.test`;
+
+ const inviteRes = await page.request.post('/api/admin/customers/invite', {
+ headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
+ data: { email },
+ failOnStatusCode: false,
+ });
+ if (!inviteRes.ok()) {
+ throw new Error(`Customer invite failed: ${inviteRes.status()} ${await inviteRes.text()}`);
+ }
+ const inviteBody = await inviteRes.json();
+ // successResponse wraps in { success, data } — accept either shape.
+ const invitation = inviteBody.data?.invitation ?? inviteBody.invitation;
+ expect(invitation?.token, 'expected invitation.token echoed from non-prod /invite response').toBeTruthy();
+ const token = invitation.token;
+
+ // Accept the invitation as the customer (no auth).
+ const acceptRes = await page.request.post('/api/customer/auth/accept-invite', {
+ headers: { 'Content-Type': 'application/json' },
+ data: {
+ token,
+ name: 'Customer Portal E2E',
+ password: CUSTOMER_PASSWORD,
+ },
+ failOnStatusCode: false,
+ });
+ if (!acceptRes.ok()) {
+ throw new Error(`Accept failed: ${acceptRes.status()} ${await acceptRes.text()}`);
+ }
+
+ return { email };
+}
+
+async function getCustomerIdByEmail(page: Page, adminToken: string, email: string): Promise {
+ const res = await page.request.get(`/api/admin/customers?search=${encodeURIComponent(email)}`, {
+ headers: { Authorization: `Bearer ${adminToken}` },
+ failOnStatusCode: false,
+ });
+ expect(res.ok()).toBeTruthy();
+ const body = await res.json();
+ const list = body.customers || body.data?.customers || body;
+ const match = (Array.isArray(list) ? list : []).find((c: any) => c.email === email);
+ expect(match, `expected customer with email ${email}`).toBeTruthy();
+ return match.id;
+}
+
+async function assignCustomerToEvent(page: Page, adminToken: string, eventId: number, customerId: number) {
+ // Update the event to include this customer's id in customer_account_ids
+ const res = await page.request.put(`/api/admin/events/${eventId}`, {
+ headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
+ data: { customer_account_ids: [customerId] },
+ failOnStatusCode: false,
+ });
+ if (!res.ok()) {
+ throw new Error(`Customer assignment failed: ${res.status()} ${await res.text()}`);
+ }
+}
+
+// ---- the actual spec ---------------------------------------------------
+
+test.describe('Customer portal — login + gallery handoff', () => {
+ test.beforeEach(async ({ page }) => {
+ // Make sure each test starts with a clean cookie jar so a leftover
+ // admin_token from a previous spec doesn't accidentally satisfy
+ // /api/customer/auth/session via the Authorization-Bearer fallback
+ // (which the customer-side specifically refuses, but the test
+ // shouldn't rely on that to pass).
+ await page.context().clearCookies();
+ });
+
+ test('customer can log in and open an assigned gallery without a password', async ({ page }) => {
+ // === setup (admin side) ===
+ const adminToken = await adminLogin(page);
+ await setCustomerPortalEnabled(page, adminToken, true);
+
+ const event = await createEventWithPhoto(page, adminToken);
+ const { email } = await inviteAndAcceptCustomer(page, adminToken);
+ const customerId = await getCustomerIdByEmail(page, adminToken, email);
+ await assignCustomerToEvent(page, adminToken, event.id, customerId);
+
+ try {
+ // === customer flow ===
+ // Login through the SPA (covers the cookie + setSession path that
+ // makes the dashboard render the assigned gallery on first paint).
+ await page.context().clearCookies();
+ await page.goto('/customer/login');
+ await page.getByLabel(/Email/i).fill(email);
+ await page.getByLabel(/Password/i).fill(CUSTOMER_PASSWORD);
+ await page.getByRole('button', { name: /Sign in/i }).click();
+
+ // Dashboard should list the assigned event by name.
+ await expect(page.getByRole('heading', { name: /Your galleries/i })).toBeVisible({ timeout: 15000 });
+ await expect(page.getByText(event.event_name, { exact: false })).toBeVisible({ timeout: 15000 });
+
+ // Click → gallery handoff. Watch for the URL change AND the absence
+ // of the per-event password prompt. Either of those failing is the
+ // primary regression this spec is designed to catch.
+ const navPromise = page.waitForURL(/\/gallery\//, { timeout: 15000 });
+ await page.getByRole('button', { name: /Open gallery/i }).first().click();
+ await navPromise;
+
+ // The password prompt would render `Enter Gallery Password` (heading
+ // or section label). It must NOT be present after the customer
+ // dashboard handoff.
+ await expect(page.getByText(/Enter Gallery Password/i)).toHaveCount(0);
+
+ // The grid tiles use the `.relative.group` selector across layouts;
+ // matches `auth-smoke.spec.ts`. At least one must render.
+ const tiles = page.locator('.relative.group');
+ await expect(tiles.first()).toBeVisible({ timeout: 20000 });
+ } finally {
+ // Clean up: turn the feature off so the next test starts from a
+ // known state. Errors are swallowed — leftover state from a failed
+ // run is something to investigate manually.
+ await setCustomerPortalEnabled(page, adminToken, false).catch(() => { /* noop */ });
+ }
+ });
+
+ test('disabling the master toggle redirects /customer/login to /admin/login and refuses the API', async ({ page }) => {
+ // Verifies the kill-switch contract on both sides:
+ // - frontend: CustomerPortalGate redirects when public-settings says off
+ // - backend: /api/customer/auth/session returns 410 Gone with code
+ // CUSTOMER_PORTAL_DISABLED
+ const adminToken = await adminLogin(page);
+ await setCustomerPortalEnabled(page, adminToken, false);
+
+ // API gate: 410 Gone (the chosen status for "feature was here, admin
+ // turned it off" — distinct from a generic 403).
+ const apiRes = await page.request.get('/api/customer/auth/session', { failOnStatusCode: false });
+ expect(apiRes.status()).toBe(410);
+ const body = await apiRes.json().catch(() => ({}));
+ expect(body.code).toBe('CUSTOMER_PORTAL_DISABLED');
+
+ // Frontend gate: CustomerPortalGate redirects /customer/* away to
+ // /admin/login. Use waitForURL so we don't race the React Router
+ // .
+ await page.goto('/customer/login');
+ await page.waitForURL(/\/admin\/login/, { timeout: 10000 });
+ expect(page.url()).toContain('/admin/login');
+ });
+});