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.

+
+ Set up your account +
+

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.

+
+ Konto einrichten +
+

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.

+
+ Set up your account +
+

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.

+
+ Konto einrichten +
+

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 ( +
+ +

{helpText}

+ + {/* Selected chips */} + {value.length > 0 && ( +
+ {value.map((c) => ( + + {c.displayName?.trim() || c.email} + {c.displayName?.trim() && c.email !== c.displayName && ( + · {c.email} + )} + {!disabled && ( + + )} + + ))} +
+ )} + + {/* 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) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); +}; + +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 */}