feat(customers): customer portal (#354) on top of feature-flags reorg
Implements the recurring-customer login surface from the-luap/picpeak#354 plugged into the maintainer's new feature-flag infrastructure (PR #443) instead of a parallel toggle. * New `customerPortal` feature flag (foundation flag for the not-yet-built calendar/quotes/bills/messaging customer surfaces). Defaults FALSE on fresh installs, TRUE on existing installs (events > 0) via migration 095 so live customer accounts don't disappear mid-deployment. * Foundation schema: customer_accounts, customer_invitations, event_customer_assignments, customer_password_resets, plus RBAC permissions customers.view / .create / .delete granted to super_admin + admin system roles. * Backend: /api/admin/customers (invite, list, search, assign, deactivate, reset password) + /api/customer/auth/* + /api/customer/* (login, dashboard, accept-invite, reset). Customer JWT bypass minted via /api/customer/events/:slug/access-token so existing gallery middleware stays untouched. * Frontend: /customer/* route tree gated by RequireFeature flag customerPortal, with login / dashboard / accept-invite / reset pages and a customer-side sidebar layout. /admin/customers and /admin/customers/:id gated identically. * Settings → Features grows a "Customers" section with a Customer portal card. The maintainer's Features tab stays the single source of truth — no parallel Advanced features tab. * CustomerAccountPicker on event create/edit forms hides itself when the flag is off; backend ignores customer_account_ids in that case instead of erroring the whole event save. Translations: en + de hand-translated. nl/pt/ru fall through to en — flagged here as needing native review. Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -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 = '<p>You\'ve been invited to create a customer account. <a href="{{invite_link}}">Set up your account</a> (expires {{expires_at}}).</p>';
|
||||
}
|
||||
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',
|
||||
`
|
||||
<h2>Welcome to your photo galleries</h2>
|
||||
<p>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.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Set up your account</a>
|
||||
</div>
|
||||
<p>This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>If you weren't expecting this email, you can safely ignore it.</p>`,
|
||||
`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',
|
||||
`
|
||||
<h2>Willkommen bei Ihren Fotogalerien</h2>
|
||||
<p>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.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Konto einrichten</a>
|
||||
</div>
|
||||
<p>Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.</p>`,
|
||||
`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();
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
};
|
||||
@@ -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 = `<p>Hello,</p>
|
||||
<p>Your photographer has triggered a password reset for your customer account.</p>
|
||||
<p><a href="{{reset_link}}">Click here to set a new password</a>. This link expires on {{expires_at}}.</p>
|
||||
<p>If you didn't expect this, you can ignore the message — your current password will keep working until you click the link.</p>`;
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
@@ -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 = `
|
||||
<h2>Welcome to your photo galleries</h2>
|
||||
<p>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.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Set up your account</a>
|
||||
</div>
|
||||
<p>This invitation expires on {{expires_at}}. If the link doesn't work, copy and paste it into your browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>If you weren't expecting this email, you can safely ignore it.</p>`;
|
||||
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 = `
|
||||
<h2>Willkommen bei Ihren Fotogalerien</h2>
|
||||
<p>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.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{invite_link}}" class="button">Konto einrichten</a>
|
||||
</div>
|
||||
<p>Diese Einladung läuft am {{expires_at}} ab. Falls der Link nicht funktioniert, kopieren Sie ihn in Ihren Browser:</p>
|
||||
<p style="word-break: break-all; font-size: 13px; color: #666;">{{invite_link}}</p>
|
||||
<p>Wenn Sie diese E-Mail nicht erwartet haben, können Sie sie ignorieren.</p>`;
|
||||
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: '<p>You\'ve been invited to create a customer account. <a href="{{invite_link}}" class="button">Set up your account</a> (expires {{expires_at}}).</p>',
|
||||
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.
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
+15
-1
@@ -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'));
|
||||
|
||||
@@ -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: '[email protected]' } }));
|
||||
await expect(
|
||||
svc.createInvitation({ email: '[email protected]', 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: '[email protected]' } }));
|
||||
await expect(
|
||||
svc.createInvitation({ email: '[email protected]', 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: '[email protected]',
|
||||
invitedById: 9,
|
||||
});
|
||||
|
||||
expect(result.email).toBe('[email protected]');
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
@@ -665,6 +669,27 @@ 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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<slug> 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' });
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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-<id>-<random>@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,
|
||||
};
|
||||
@@ -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)
|
||||
@@ -23,18 +37,29 @@ async function revokeToken(token, reason, metadata = {}) {
|
||||
// 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
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
+104
-28
@@ -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 <admin token>` 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,19 +188,45 @@ 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;
|
||||
}
|
||||
|
||||
function getGalleryTokenFromRequest(req, slug) {
|
||||
const header = req.headers?.authorization;
|
||||
if (header && header.startsWith('Bearer ')) {
|
||||
return header.substring(7);
|
||||
/**
|
||||
* 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 <admin token>` 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) {
|
||||
// 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,
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
<Route element={<RequireFeature flag="userManagement" />}>
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
</Route>
|
||||
{/* Customer accounts (#354) — admin-side management.
|
||||
Hidden from sidebar + redirected away when the
|
||||
customerPortal flag is off. */}
|
||||
<Route element={<RequireFeature flag="customerPortal" />}>
|
||||
<Route path="customers" element={<CustomerManagementPage />} />
|
||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
||||
@@ -153,6 +174,38 @@ function App() {
|
||||
{/* Public invitation acceptance page */}
|
||||
<Route path="/invite/:token" element={<AcceptInvitePage />} />
|
||||
|
||||
{/* 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. */}
|
||||
<Route element={<RequireFeature flag="customerPortal" fallback="/admin/login" />}>
|
||||
<Route path="/customer/*" element={
|
||||
<CustomerAuthProvider>
|
||||
<Routes>
|
||||
{/* Public surfaces: login, accept-invite, reset —
|
||||
no CustomerLayout (their own branded shells). */}
|
||||
<Route path="login" element={<CustomerLoginPage />} />
|
||||
<Route path="invite/:token" element={<CustomerAcceptInvitePage />} />
|
||||
<Route path="reset-password/:token" element={<CustomerResetPasswordPage />} />
|
||||
|
||||
{/* Authenticated surfaces share the sidebar layout
|
||||
(Outlet pattern, mirrors AdminLayout). The
|
||||
CustomerLayout itself enforces auth — bouncing
|
||||
unauthenticated visitors to /customer/login. */}
|
||||
<Route element={<CustomerLayout />}>
|
||||
<Route path="dashboard" element={<CustomerDashboardPage />} />
|
||||
<Route path="calendar" element={<CustomerCalendarPage />} />
|
||||
<Route path="quotes" element={<CustomerQuotesPage />} />
|
||||
<Route path="bills" element={<CustomerBillsPage />} />
|
||||
<Route path="profile" element={<CustomerProfilePage />} />
|
||||
</Route>
|
||||
|
||||
<Route index element={<Navigate to="/customer/dashboard" replace />} />
|
||||
</Routes>
|
||||
</CustomerAuthProvider>
|
||||
} />
|
||||
</Route>
|
||||
|
||||
{/* Public legal pages */}
|
||||
<Route path="/impressum" element={<LegalPage />} />
|
||||
<Route path="/datenschutz" element={<LegalPage />} />
|
||||
|
||||
@@ -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<AdminSidebarProps> = ({ isOpen, onClose }) => {
|
||||
|
||||
@@ -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<Props> = ({ 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<CustomerAccountSummary[]>([]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className="relative">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('events.customerPicker.label', 'Customer accounts')}
|
||||
</label>
|
||||
<p className="text-xs text-muted-theme mb-2">{helpText}</p>
|
||||
|
||||
{/* Selected chips */}
|
||||
{value.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{value.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-elevated, #f5f5f5)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-surface-border, #e5e5e5)',
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{c.displayName?.trim() || c.email}</span>
|
||||
{c.displayName?.trim() && c.email !== c.displayName && (
|
||||
<span className="text-muted-theme">· {c.email}</span>
|
||||
)}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(c.id)}
|
||||
className="ml-1 -mr-1 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 p-0.5"
|
||||
aria-label={t('events.customerPicker.removeAria', 'Remove {{name}}', { name: c.email })}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => { 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && query.trim() !== '' && (
|
||||
<div
|
||||
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface, #ffffff)',
|
||||
borderColor: 'var(--color-surface-border, #e5e5e5)',
|
||||
}}
|
||||
>
|
||||
{isSearching ? (
|
||||
<div className="px-3 py-3 text-sm text-muted-theme">
|
||||
{t('events.customerPicker.searching', 'Searching…')}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-muted-theme">
|
||||
{t('events.customerPicker.noResults', 'No matches. Invite this customer from /admin/customers first.')}
|
||||
</div>
|
||||
) : (
|
||||
<ul role="listbox">
|
||||
{results.map((r) => (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => select(r)}
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
|
||||
>
|
||||
<UserPlus className="w-4 h-4 text-muted-theme flex-shrink-0" />
|
||||
<span className="flex-1 truncate">{labelFor(r)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerAccountPicker;
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
const CustomerAuthContext = createContext<CustomerAuthContextType | undefined>(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<ProviderProps> = ({ children }) => {
|
||||
const [customer, setCustomerState] = useState<CustomerProfile | null>(null);
|
||||
const [features, setFeatures] = useState<CustomerFeatureFlags>(DEFAULT_FEATURES);
|
||||
const [branding, setBranding] = useState<CustomerBrandingFlags>(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<string | null>(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 (
|
||||
<CustomerAuthContext.Provider
|
||||
value={{
|
||||
isAuthenticated: !!customer,
|
||||
customer,
|
||||
features,
|
||||
branding,
|
||||
isLoading,
|
||||
error,
|
||||
setCustomer,
|
||||
setSession,
|
||||
logout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CustomerAuthContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* 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. */}
|
||||
<Section title={t('settings.features.sections.customers', 'Customers')}>
|
||||
<FeatureCard
|
||||
icon={UserCog}
|
||||
title={t('settings.features.customerPortal.title', 'Customer portal')}
|
||||
description={t(
|
||||
'settings.features.customerPortal.description',
|
||||
'Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Foundation for Calendar / Quotes / Bills (which only render in the customer dashboard when this is on).',
|
||||
)}
|
||||
status="beta"
|
||||
statusLabel={statusLabel('beta')}
|
||||
sidebarLabel={t('navigation.customers', 'Customers')}
|
||||
enabled={staged.customerPortal}
|
||||
onToggle={(next) => setFlag('customerPortal', next)}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* Communication */}
|
||||
<Section title={t('settings.features.sections.communication', 'Communication')}>
|
||||
<FeatureCard
|
||||
|
||||
@@ -167,7 +167,8 @@
|
||||
"backup": "Backup & Wiederherstellung",
|
||||
"cmsPages": "CMS-Seiten",
|
||||
"users": "Benutzer",
|
||||
"calendar": "Kalender"
|
||||
"calendar": "Kalender",
|
||||
"customers": "Kunden"
|
||||
},
|
||||
"eventTypes": {
|
||||
"title": "Veranstaltungsarten",
|
||||
@@ -1446,7 +1447,8 @@
|
||||
"communication": "Kommunikation",
|
||||
"scheduling": "Terminplanung",
|
||||
"sales": "Vertrieb",
|
||||
"insights": "Auswertungen & Zugriff"
|
||||
"insights": "Auswertungen & Zugriff",
|
||||
"customers": "Kunden"
|
||||
},
|
||||
"status": {
|
||||
"stable": "stabil",
|
||||
@@ -1492,6 +1494,10 @@
|
||||
"title": "Benutzerverwaltung",
|
||||
"description": "Multi-Admin-Unterstützung mit rollenbasierten Berechtigungen. Deaktivieren Sie dies, wenn Sie ein Einzelbetreiber sind.",
|
||||
"warning": "Bestehende Benutzerkonten bleiben gültig; die Admin-Oberfläche für deren Verwaltung wird ausgeblendet, bis Sie dies wieder aktivieren."
|
||||
},
|
||||
"customerPortal": {
|
||||
"title": "Kundenportal",
|
||||
"description": "Persistente Kunden-Logins. Wiederkehrende Kunden sehen alle zugeordneten Galerien an einem Ort — keine Passwörter pro Event. Grundlage für Kalender / Angebote / Rechnungen (die nur im Kundendashboard erscheinen, wenn diese Option aktiviert ist)."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2540,5 +2546,290 @@
|
||||
"movedToCategory_other": "{{count}} Fotos nach {{category}} verschoben",
|
||||
"moveToCategoryFailed": "Fotos konnten nicht in die Kategorie verschoben werden",
|
||||
"moveToCategory": "In Kategorie verschieben"
|
||||
},
|
||||
"customer": {
|
||||
"login": {
|
||||
"title": "Kunden-Login",
|
||||
"subtitle": "Zugriff auf alle Ihre Fotogalerien an einem Ort.",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"emailRequired": "E-Mail ist erforderlich",
|
||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail ein",
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
"showPassword": "Passwort anzeigen",
|
||||
"hidePassword": "Passwort verbergen",
|
||||
"signIn": "Anmelden",
|
||||
"loginSuccess": "Willkommen zurück!",
|
||||
"invalidCredentials": "E-Mail oder Passwort ist falsch",
|
||||
"tooManyAttempts": "Zu viele Versuche — bitte später erneut versuchen.",
|
||||
"networkError": "Server nicht erreichbar. Bitte erneut versuchen.",
|
||||
"generalError": "Anmeldung fehlgeschlagen. Bitte erneut versuchen.",
|
||||
"acceptedToast": "Konto bereit — bitte anmelden.",
|
||||
"adminHint": "Sie suchen das Admin-Panel? Besuchen Sie /admin/login.",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"passwordPlaceholder": "Dein Passwort",
|
||||
"needHelp": "Brauchst du Hilfe?",
|
||||
"poweredBy": "Bereitgestellt von PicPeak"
|
||||
},
|
||||
"acceptInvite": {
|
||||
"title": "Konto einrichten",
|
||||
"invalidToken": "Dieser Einladungslink ist ungültig oder abgelaufen. Bitte kontaktieren Sie Ihren Fotografen für eine neue Einladung.",
|
||||
"emailWillBe": "Ihre Konto-E-Mail wird ",
|
||||
"invitedBy": ", eingeladen von ",
|
||||
"name": "Ihr Name",
|
||||
"nameRequired": "Bitte geben Sie Ihren Namen ein",
|
||||
"password": "Passwort wählen",
|
||||
"confirm": "Passwort bestätigen",
|
||||
"passwordTooShort": "Passwort muss mindestens 8 Zeichen haben",
|
||||
"passwordsMismatch": "Passwörter stimmen nicht überein",
|
||||
"passwordHint": "Mindestens 8 Zeichen, mit einem Großbuchstaben und einer Zahl.",
|
||||
"submit": "Konto erstellen",
|
||||
"successToast": "Konto erstellt — bitte anmelden.",
|
||||
"alreadyExists": "Es existiert bereits ein Konto mit dieser E-Mail. Bitte melden Sie sich stattdessen an.",
|
||||
"invalidSubmission": "Konto konnte nicht erstellt werden.",
|
||||
"generalError": "Konto konnte nicht erstellt werden. Bitte erneut versuchen.",
|
||||
"subtitle": "Bestätige oder ergänze deine Daten. Du kannst alles später im Profil bearbeiten.",
|
||||
"displayName": "Anzeigename",
|
||||
"section": {
|
||||
"personal": "Persönlich",
|
||||
"contact": "Kontakt & Firma (optional)",
|
||||
"address": "Rechnungsadresse (optional)",
|
||||
"password": "Passwort wählen"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Deine Galerien",
|
||||
"subtitle": "Klicke auf eine Galerie, um sie zu öffnen. Der Download-Button bündelt alle Fotos als ZIP.",
|
||||
"loadError": "Galerien konnten nicht geladen werden. Bitte erneut versuchen.",
|
||||
"emptyTitle": "Noch keine Galerien",
|
||||
"emptyBody": "Sobald Ihr Fotograf Sie einer Galerie zuweist, erscheint sie hier.",
|
||||
"openAria": "Galerie {{name}} öffnen",
|
||||
"opening": "Wird geöffnet…",
|
||||
"expiresOn": "Läuft ab am {{date}}",
|
||||
"expiredOn": "Abgelaufen am {{date}}",
|
||||
"eventExpired": "Diese Galerie ist abgelaufen.",
|
||||
"eventForbidden": "Sie haben keinen Zugriff mehr auf diese Galerie.",
|
||||
"openError": "Galerie konnte nicht geöffnet werden. Bitte erneut versuchen.",
|
||||
"download": "Herunterladen",
|
||||
"preparingDownload": "Wird vorbereitet…",
|
||||
"quickDownloadAria": "Alle Fotos für {{name}} herunterladen",
|
||||
"downloadStarted": "Download gestartet für {{name}}",
|
||||
"downloadError": "Download konnte nicht gestartet werden. Bitte erneut versuchen.",
|
||||
"sortLabel": "Sortieren nach",
|
||||
"sortNewest": "Neueste zuerst",
|
||||
"sortOldest": "Älteste zuerst",
|
||||
"sortName": "Nach Name",
|
||||
"open": "Öffnen"
|
||||
},
|
||||
"layout": {
|
||||
"greeting": "Hallo, {{name}}"
|
||||
},
|
||||
"nav": {
|
||||
"galleries": "Galerien",
|
||||
"calendar": "Kalender",
|
||||
"quotes": "Angebote",
|
||||
"bills": "Rechnungen",
|
||||
"profile": "Profil",
|
||||
"soon": "Bald"
|
||||
},
|
||||
"comingSoon": {
|
||||
"tag": "Demnächst"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Kalender",
|
||||
"body": "Bevorstehende Termine, Liefertermine für Galerien und weitere Shoot-Ereignisse landen hier. Wir geben Bescheid, sobald es soweit ist."
|
||||
},
|
||||
"quotes": {
|
||||
"title": "Angebote",
|
||||
"body": "Angebote für kommende Shoots an einem Ort prüfen und annehmen. Wir bauen das noch — bis dahin schickt dir dein Fotograf Angebote wie gewohnt."
|
||||
},
|
||||
"bills": {
|
||||
"title": "Rechnungen",
|
||||
"body": "Rechnungen und Zahlungshistorie zu deinen Shoots findest du hier. Wir senden eine E-Mail, sobald es live ist."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Kundenprofil",
|
||||
"subtitle": "Halte Kontakt- und Rechnungsdaten aktuell — sie erscheinen auf Angeboten und Rechnungen, sobald diese Funktionen live sind.",
|
||||
"savedToast": "Profil gespeichert",
|
||||
"saveError": "Profil konnte nicht gespeichert werden.",
|
||||
"loadError": "Profil konnte nicht geladen werden.",
|
||||
"save": "Änderungen speichern",
|
||||
"salutation": {
|
||||
"none": "— Keine Angabe —",
|
||||
"herr": "Herr",
|
||||
"frau": "Frau",
|
||||
"mx": "Mx",
|
||||
"dr": "Dr."
|
||||
},
|
||||
"section": {
|
||||
"personal": "Persönliche Angaben",
|
||||
"contact": "Kontakt & Firma",
|
||||
"address": "Rechnungsadresse",
|
||||
"password": "Passwort ändern"
|
||||
},
|
||||
"field": {
|
||||
"email": "E-Mail (Login)",
|
||||
"emailHint": "Wende dich an deinen Fotografen, wenn du deine Login-E-Mail ändern möchtest.",
|
||||
"salutation": "Anrede",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"displayName": "Anzeigename",
|
||||
"displayNameHint": "So begrüßen wir dich im Dashboard.",
|
||||
"phone": "Telefon",
|
||||
"companyName": "Firmenname",
|
||||
"vatId": "USt-IdNr.",
|
||||
"addressLine1": "Adresszeile 1",
|
||||
"addressLine2": "Adresszeile 2",
|
||||
"postalCode": "Postleitzahl",
|
||||
"city": "Stadt",
|
||||
"state": "Bundesland / Region",
|
||||
"countryCode": "Land"
|
||||
},
|
||||
"password": {
|
||||
"current": "Aktuelles Passwort",
|
||||
"next": "Neues Passwort",
|
||||
"confirm": "Neues Passwort bestätigen",
|
||||
"submit": "Passwort aktualisieren",
|
||||
"hint": "Mindestens 8 Zeichen, ein Großbuchstabe und eine Ziffer.",
|
||||
"currentRequired": "Aktuelles Passwort eingeben",
|
||||
"tooShort": "Mindestens 8 Zeichen",
|
||||
"mismatch": "Passwörter stimmen nicht überein",
|
||||
"wrong": "Aktuelles Passwort ist falsch",
|
||||
"savedToast": "Passwort aktualisiert",
|
||||
"error": "Passwort konnte nicht geändert werden"
|
||||
}
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Passwort zurücksetzen",
|
||||
"invalidToken": "Dieser Link ist ungültig oder abgelaufen. Bitte fordere bei deinem Fotografen einen neuen an.",
|
||||
"forEmail": "Neues Passwort wird gesetzt für ",
|
||||
"password": "Neues Passwort",
|
||||
"confirm": "Neues Passwort bestätigen",
|
||||
"submit": "Passwort aktualisieren",
|
||||
"hint": "Mindestens 8 Zeichen, ein Großbuchstabe und eine Ziffer.",
|
||||
"tooShort": "Passwort muss mindestens 8 Zeichen lang sein",
|
||||
"mismatch": "Passwörter stimmen nicht überein",
|
||||
"successToast": "Passwort aktualisiert. Bitte einloggen.",
|
||||
"invalidSubmission": "Passwort konnte nicht aktualisiert werden.",
|
||||
"generalError": "Passwort konnte nicht aktualisiert werden. Bitte erneut versuchen."
|
||||
}
|
||||
},
|
||||
"customers": {
|
||||
"pageTitle": "Kunden",
|
||||
"pageSubtitle": "Wiederkehrende Kundenkonten, die sich unter /customer/login anmelden können.",
|
||||
"unnamed": "Unbenannt",
|
||||
"empty": "Noch keine Kunden. Klicken Sie auf „Kunden einladen“, um einen hinzuzufügen.",
|
||||
"loadError": "Kunden konnten nicht geladen werden",
|
||||
"loadInvitationsError": "Einladungen konnten nicht geladen werden",
|
||||
"tabs": {
|
||||
"customers": "Kunden",
|
||||
"invitations": "Einladungen"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Nach E-Mail, Name oder Firma suchen"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"email": "E-Mail",
|
||||
"company": "Firma",
|
||||
"eventCount": "Events",
|
||||
"lastLogin": "Letzte Anmeldung",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"active": "Aktiv",
|
||||
"inactive": "Deaktiviert"
|
||||
},
|
||||
"invite": {
|
||||
"button": "Kunden einladen",
|
||||
"title": "Kunden einladen",
|
||||
"description": "Der Kunde erhält eine E-Mail mit einem Link zum Einrichten des Kontos. Nach Annahme können Sie ihn Events zuordnen.",
|
||||
"email": "E-Mail",
|
||||
"submit": "Einladung senden",
|
||||
"success": "Einladung gesendet",
|
||||
"error": "Einladung konnte nicht gesendet werden.",
|
||||
"conflict": "Ein Kunde mit dieser E-Mail existiert bereits oder hat eine offene Einladung.",
|
||||
"invalidEmail": "Bitte geben Sie eine gültige E-Mail ein",
|
||||
"showPrefill": "+ Kontaktdaten hinzufügen (optional)",
|
||||
"hidePrefill": "− Kontaktdaten ausblenden",
|
||||
"prefillHint": "Alles, was du ausfüllst, wird auf der Anmeldeseite des Kunden vorausgefüllt — er kann es weiterhin bearbeiten."
|
||||
},
|
||||
"invitations": {
|
||||
"empty": "Keine offenen Einladungen.",
|
||||
"email": "E-Mail",
|
||||
"invitedBy": "Eingeladen von",
|
||||
"expiresAt": "Läuft ab",
|
||||
"createdAt": "Erstellt",
|
||||
"cancel": "Stornieren"
|
||||
},
|
||||
"deactivate": {
|
||||
"button": "Deaktivieren",
|
||||
"title": "Kunde deaktivieren?",
|
||||
"body": "Der Kunde kann sich nicht mehr anmelden. Sie können ihn später erneut einladen.",
|
||||
"success": "Kunde deaktiviert",
|
||||
"error": "Kunde konnte nicht deaktiviert werden"
|
||||
},
|
||||
"cancelInvitation": {
|
||||
"title": "Einladung stornieren?",
|
||||
"body": "Der Einladungslink funktioniert sofort nicht mehr.",
|
||||
"success": "Einladung storniert",
|
||||
"error": "Einladung konnte nicht storniert werden"
|
||||
},
|
||||
"detail": {
|
||||
"loadError": "Kunde konnte nicht geladen werden",
|
||||
"saved": "Kunde gespeichert",
|
||||
"saveError": "Änderungen konnten nicht gespeichert werden.",
|
||||
"emailConflict": "Diese E-Mail wird bereits von einem anderen Kunden verwendet.",
|
||||
"save": "Änderungen speichern",
|
||||
"expires": "läuft ab",
|
||||
"accountSection": "Konto",
|
||||
"personalSection": "Persönliche Daten",
|
||||
"billingSection": "Adresse & Rechnung",
|
||||
"notesSection": "Interne Notizen",
|
||||
"eventsSection": "Zugewiesene Events",
|
||||
"noEvents": "Noch keinem Event zugewiesen. Fügen Sie diesen Kunden über das Event-Formular hinzu.",
|
||||
"email": "E-Mail",
|
||||
"preferredLanguage": "Bevorzugte Sprache",
|
||||
"salutation": "Anrede",
|
||||
"salutationNone": "—",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"displayName": "Anzeigename",
|
||||
"phone": "Telefon",
|
||||
"company": "Firma",
|
||||
"billingEmail": "Rechnungs-E-Mail",
|
||||
"vatId": "USt-IdNr.",
|
||||
"addressLine1": "Adresse Zeile 1",
|
||||
"addressLine2": "Adresse Zeile 2",
|
||||
"postalCode": "Postleitzahl",
|
||||
"city": "Stadt",
|
||||
"state": "Bundesland / Region",
|
||||
"countryCode": "Land (ISO-2)",
|
||||
"notesHint": "Nur für Administratoren sichtbar. Wird dem Kunden nie gezeigt.",
|
||||
"featuresSection": "Kundenfunktionen",
|
||||
"featuresHint": "Pro-Kunde-Überschreibungen für die Kundenoberflächen-Tabs. Die globalen Schalter in Einstellungen → Kundenoberfläche sind der Master-Schalter — wenn global AUS, sieht niemand den Tab, unabhängig von der Einstellung hier. Standard ist AN, schalte einen Eintrag AUS, um diesen Tab für diesen Kunden auszublenden.",
|
||||
"passwordSection": "Kontoaktionen",
|
||||
"passwordHint": "Sendet einen einmalig nutzbaren Reset-Link (7 Tage gültig) an die E-Mail-Adresse des Kunden. Das aktuelle Passwort bleibt gültig, bis der Kunde den Link öffnet und ein neues setzt.",
|
||||
"passwordReset": {
|
||||
"button": "Passwort-Reset-E-Mail senden",
|
||||
"success": "Passwort-Reset-E-Mail gesendet",
|
||||
"error": "Passwort-Reset konnte nicht gesendet werden",
|
||||
"inactive": "Aktiviere den Kunden, bevor du einen Reset sendest."
|
||||
}
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Reaktivieren",
|
||||
"success": "Kunde reaktiviert",
|
||||
"error": "Kunde konnte nicht reaktiviert werden"
|
||||
},
|
||||
"erase": {
|
||||
"button": "Kundendaten löschen",
|
||||
"title": "Kundendaten löschen?",
|
||||
"body": "Entfernt Name, E-Mail, Telefon, Adresse, Firma und Login-Daten des Kunden. Der Kontoeintrag bleibt, damit historische Galerie-Zugriffe und Audit-Logs ihn weiter referenzieren können. Dies ist unwiderruflich — die Daten können danach nicht wiederhergestellt werden.",
|
||||
"confirm": "Endgültig löschen",
|
||||
"confirmInFlight": "Lösche…",
|
||||
"success": "Kunde gelöscht",
|
||||
"error": "Kunde konnte nicht gelöscht werden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@
|
||||
"backup": "Backup & Restore",
|
||||
"cmsPages": "CMS Pages",
|
||||
"users": "Users",
|
||||
"calendar": "Calendar"
|
||||
"calendar": "Calendar",
|
||||
"customers": "Customers"
|
||||
},
|
||||
"archives": {
|
||||
"title": "Archives",
|
||||
@@ -1085,7 +1086,8 @@
|
||||
"communication": "Communication",
|
||||
"scheduling": "Scheduling",
|
||||
"sales": "Sales",
|
||||
"insights": "Insights & Access"
|
||||
"insights": "Insights & Access",
|
||||
"customers": "Customers"
|
||||
},
|
||||
"status": {
|
||||
"stable": "stable",
|
||||
@@ -1131,6 +1133,10 @@
|
||||
"title": "User Management",
|
||||
"description": "Multi-admin support with role-based permissions. Turn off if you're a single-operator studio.",
|
||||
"warning": "Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this."
|
||||
},
|
||||
"customerPortal": {
|
||||
"title": "Customer portal",
|
||||
"description": "Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Foundation for Calendar / Quotes / Bills (which only render in the customer dashboard when this is on)."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2540,5 +2546,290 @@
|
||||
"movedToCategory_other": "{{count}} photos moved to {{category}}",
|
||||
"moveToCategoryFailed": "Failed to move photos to category",
|
||||
"moveToCategory": "Move to Category"
|
||||
},
|
||||
"customer": {
|
||||
"login": {
|
||||
"title": "Customer login",
|
||||
"subtitle": "Access all of your photo galleries in one place.",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"emailRequired": "Email is required",
|
||||
"invalidEmail": "Please enter a valid email",
|
||||
"passwordRequired": "Password is required",
|
||||
"showPassword": "Show password",
|
||||
"hidePassword": "Hide password",
|
||||
"signIn": "Sign in",
|
||||
"loginSuccess": "Welcome back!",
|
||||
"invalidCredentials": "Invalid email or password",
|
||||
"tooManyAttempts": "Too many attempts — please try again later.",
|
||||
"networkError": "Could not reach the server. Please try again.",
|
||||
"generalError": "Login failed. Please try again.",
|
||||
"acceptedToast": "Account ready — please log in.",
|
||||
"adminHint": "Looking for the admin panel? Visit /admin/login.",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"passwordPlaceholder": "Your password",
|
||||
"needHelp": "Need help?",
|
||||
"poweredBy": "Powered by PicPeak"
|
||||
},
|
||||
"acceptInvite": {
|
||||
"title": "Set up your account",
|
||||
"invalidToken": "This invitation link is invalid or has expired. Please contact your photographer for a new invitation.",
|
||||
"emailWillBe": "Your account email will be ",
|
||||
"invitedBy": ", invited by ",
|
||||
"name": "Your name",
|
||||
"nameRequired": "Please enter your name",
|
||||
"password": "Choose a password",
|
||||
"confirm": "Confirm password",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"passwordsMismatch": "Passwords do not match",
|
||||
"passwordHint": "At least 8 characters, with one uppercase letter and one number.",
|
||||
"submit": "Create account",
|
||||
"successToast": "Account created — please log in.",
|
||||
"alreadyExists": "An account with this email already exists. Please log in instead.",
|
||||
"invalidSubmission": "Could not create your account.",
|
||||
"generalError": "Could not create your account. Please try again.",
|
||||
"subtitle": "Confirm or fill in your details. You can edit anything from the profile page later.",
|
||||
"displayName": "Display name",
|
||||
"section": {
|
||||
"personal": "Personal",
|
||||
"contact": "Contact & business (optional)",
|
||||
"address": "Billing address (optional)",
|
||||
"password": "Choose a password"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Your galleries",
|
||||
"subtitle": "Click a gallery to open it. The Download button bundles every photo as a zip.",
|
||||
"loadError": "Could not load your galleries. Please try again.",
|
||||
"emptyTitle": "No galleries yet",
|
||||
"emptyBody": "Once your photographer assigns you to a gallery, it will appear here.",
|
||||
"openAria": "Open gallery {{name}}",
|
||||
"opening": "Opening…",
|
||||
"expiresOn": "Expires {{date}}",
|
||||
"expiredOn": "Expired {{date}}",
|
||||
"eventExpired": "This gallery has expired.",
|
||||
"eventForbidden": "You no longer have access to this gallery.",
|
||||
"openError": "Could not open this gallery. Please try again.",
|
||||
"download": "Download",
|
||||
"preparingDownload": "Preparing…",
|
||||
"quickDownloadAria": "Download all photos for {{name}}",
|
||||
"downloadStarted": "Download started for {{name}}",
|
||||
"downloadError": "Could not start the download. Please try again.",
|
||||
"sortLabel": "Sort by",
|
||||
"sortNewest": "Newest first",
|
||||
"sortOldest": "Oldest first",
|
||||
"sortName": "By name",
|
||||
"open": "Open"
|
||||
},
|
||||
"layout": {
|
||||
"greeting": "Hi, {{name}}"
|
||||
},
|
||||
"nav": {
|
||||
"galleries": "Galleries",
|
||||
"calendar": "Calendar",
|
||||
"quotes": "Quotes",
|
||||
"bills": "Bills",
|
||||
"profile": "Profile",
|
||||
"soon": "Soon"
|
||||
},
|
||||
"comingSoon": {
|
||||
"tag": "Coming soon"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
"body": "Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
|
||||
},
|
||||
"quotes": {
|
||||
"title": "Quotes",
|
||||
"body": "Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
|
||||
},
|
||||
"bills": {
|
||||
"title": "Bills",
|
||||
"body": "Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Customer profile",
|
||||
"subtitle": "Keep your contact and billing details up to date — they're shown on quotes and invoices once those features go live.",
|
||||
"savedToast": "Profile saved",
|
||||
"saveError": "Could not save profile.",
|
||||
"loadError": "Could not load your profile.",
|
||||
"save": "Save changes",
|
||||
"salutation": {
|
||||
"none": "— Not specified —",
|
||||
"herr": "Mr.",
|
||||
"frau": "Ms.",
|
||||
"mx": "Mx",
|
||||
"dr": "Dr."
|
||||
},
|
||||
"section": {
|
||||
"personal": "Personal information",
|
||||
"contact": "Contact & business",
|
||||
"address": "Billing address",
|
||||
"password": "Change password"
|
||||
},
|
||||
"field": {
|
||||
"email": "Email (login)",
|
||||
"emailHint": "Contact your photographer if you need to change your login email.",
|
||||
"salutation": "Salutation",
|
||||
"firstName": "First name",
|
||||
"lastName": "Last name",
|
||||
"displayName": "Display name",
|
||||
"displayNameHint": "How we greet you in the dashboard.",
|
||||
"phone": "Phone",
|
||||
"companyName": "Company name",
|
||||
"vatId": "VAT ID",
|
||||
"addressLine1": "Address line 1",
|
||||
"addressLine2": "Address line 2",
|
||||
"postalCode": "Postal code",
|
||||
"city": "City",
|
||||
"state": "State / region",
|
||||
"countryCode": "Country"
|
||||
},
|
||||
"password": {
|
||||
"current": "Current password",
|
||||
"next": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"submit": "Update password",
|
||||
"hint": "At least 8 characters with one uppercase letter and one number.",
|
||||
"currentRequired": "Enter your current password",
|
||||
"tooShort": "At least 8 characters",
|
||||
"mismatch": "Passwords do not match",
|
||||
"wrong": "Current password is incorrect",
|
||||
"savedToast": "Password updated",
|
||||
"error": "Could not change password"
|
||||
}
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset your password",
|
||||
"invalidToken": "This reset link is invalid or has expired. Please ask your photographer to send a new one.",
|
||||
"forEmail": "Setting a new password for ",
|
||||
"password": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"submit": "Update password",
|
||||
"hint": "At least 8 characters with one uppercase letter and one number.",
|
||||
"tooShort": "Password must be at least 8 characters",
|
||||
"mismatch": "Passwords do not match",
|
||||
"successToast": "Password updated. Please log in.",
|
||||
"invalidSubmission": "Could not update your password.",
|
||||
"generalError": "Could not update your password. Please try again."
|
||||
}
|
||||
},
|
||||
"customers": {
|
||||
"pageTitle": "Customers",
|
||||
"pageSubtitle": "Recurring customer accounts that can log in at /customer/login.",
|
||||
"unnamed": "Unnamed",
|
||||
"empty": "No customers yet. Click \"Invite customer\" to add one.",
|
||||
"loadError": "Could not load customers",
|
||||
"loadInvitationsError": "Could not load invitations",
|
||||
"tabs": {
|
||||
"customers": "Customers",
|
||||
"invitations": "Invitations"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search by email, name, or company"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"email": "Email",
|
||||
"company": "Company",
|
||||
"eventCount": "Events",
|
||||
"lastLogin": "Last login",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Deactivated"
|
||||
},
|
||||
"invite": {
|
||||
"button": "Invite customer",
|
||||
"title": "Invite a customer",
|
||||
"description": "They will receive an email with a link to set up their account. Once they have accepted, you can assign them to events.",
|
||||
"email": "Email",
|
||||
"submit": "Send invitation",
|
||||
"success": "Invitation sent",
|
||||
"error": "Could not send invitation.",
|
||||
"conflict": "A customer with this email already exists or has a pending invitation.",
|
||||
"invalidEmail": "Please enter a valid email",
|
||||
"showPrefill": "+ Add contact details (optional)",
|
||||
"hidePrefill": "− Hide contact details",
|
||||
"prefillHint": "Anything you fill in will be pre-populated on the customer's sign-up page — they can still edit it."
|
||||
},
|
||||
"invitations": {
|
||||
"empty": "No pending invitations.",
|
||||
"email": "Email",
|
||||
"invitedBy": "Invited by",
|
||||
"expiresAt": "Expires",
|
||||
"createdAt": "Created",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"deactivate": {
|
||||
"button": "Deactivate",
|
||||
"title": "Deactivate customer?",
|
||||
"body": "They will no longer be able to log in. You can re-invite them later.",
|
||||
"success": "Customer deactivated",
|
||||
"error": "Could not deactivate customer"
|
||||
},
|
||||
"cancelInvitation": {
|
||||
"title": "Cancel invitation?",
|
||||
"body": "The invitation link will stop working immediately.",
|
||||
"success": "Invitation cancelled",
|
||||
"error": "Could not cancel invitation"
|
||||
},
|
||||
"detail": {
|
||||
"loadError": "Could not load customer",
|
||||
"saved": "Customer saved",
|
||||
"saveError": "Could not save changes.",
|
||||
"emailConflict": "That email is already in use by another customer.",
|
||||
"save": "Save changes",
|
||||
"expires": "expires",
|
||||
"accountSection": "Account",
|
||||
"personalSection": "Personal information",
|
||||
"billingSection": "Address & billing",
|
||||
"notesSection": "Internal notes",
|
||||
"eventsSection": "Assigned events",
|
||||
"noEvents": "Not assigned to any events yet. Add this customer to an event from the event form.",
|
||||
"email": "Email",
|
||||
"preferredLanguage": "Preferred language",
|
||||
"salutation": "Salutation",
|
||||
"salutationNone": "—",
|
||||
"firstName": "First name",
|
||||
"lastName": "Last name",
|
||||
"displayName": "Display name",
|
||||
"phone": "Phone",
|
||||
"company": "Company",
|
||||
"billingEmail": "Billing email",
|
||||
"vatId": "VAT / tax ID",
|
||||
"addressLine1": "Address line 1",
|
||||
"addressLine2": "Address line 2",
|
||||
"postalCode": "Postal code",
|
||||
"city": "City",
|
||||
"state": "State / region",
|
||||
"countryCode": "Country (ISO 2)",
|
||||
"notesHint": "Visible only to admins. Never shown to the customer.",
|
||||
"featuresSection": "Customer features",
|
||||
"featuresHint": "Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Customer Surface are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.",
|
||||
"passwordSection": "Account actions",
|
||||
"passwordHint": "Sends a 7-day single-use reset link to the customer's email. The customer's current password keeps working until they click the link and set a new one.",
|
||||
"passwordReset": {
|
||||
"button": "Send password reset email",
|
||||
"success": "Password reset email sent",
|
||||
"error": "Could not send password reset",
|
||||
"inactive": "Reactivate the customer before sending a reset."
|
||||
}
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Reactivate",
|
||||
"success": "Customer reactivated",
|
||||
"error": "Could not reactivate customer"
|
||||
},
|
||||
"erase": {
|
||||
"button": "Erase customer data",
|
||||
"title": "Erase customer data?",
|
||||
"body": "Removes the customer's name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.",
|
||||
"confirm": "Erase permanently",
|
||||
"confirmInFlight": "Erasing…",
|
||||
"success": "Customer erased",
|
||||
"error": "Could not erase customer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,8 @@
|
||||
"backup": "Back-up en herstel",
|
||||
"cmsPages": "CMS-pagina's",
|
||||
"users": "Gebruikers",
|
||||
"calendar": "Agenda"
|
||||
"calendar": "Agenda",
|
||||
"customers": "Customers"
|
||||
},
|
||||
"archives": {
|
||||
"title": "Archieven",
|
||||
@@ -2540,5 +2541,290 @@
|
||||
"movedToCategory_other": "{{count}} foto's verplaatst naar {{category}}",
|
||||
"moveToCategoryFailed": "Verplaatsen naar categorie mislukt",
|
||||
"moveToCategory": "Naar categorie verplaatsen"
|
||||
},
|
||||
"customer": {
|
||||
"login": {
|
||||
"title": "Klantlogin",
|
||||
"subtitle": "Bekijk al uw fotogalerieën op één plek.",
|
||||
"email": "E-mail",
|
||||
"password": "Wachtwoord",
|
||||
"emailRequired": "E-mail is verplicht",
|
||||
"invalidEmail": "Voer een geldig e-mailadres in",
|
||||
"passwordRequired": "Wachtwoord is verplicht",
|
||||
"showPassword": "Wachtwoord tonen",
|
||||
"hidePassword": "Wachtwoord verbergen",
|
||||
"signIn": "Inloggen",
|
||||
"loginSuccess": "Welkom terug!",
|
||||
"invalidCredentials": "Ongeldig e-mailadres of wachtwoord",
|
||||
"tooManyAttempts": "Te veel pogingen — probeer het later opnieuw.",
|
||||
"networkError": "Kan de server niet bereiken. Probeer het opnieuw.",
|
||||
"generalError": "Inloggen mislukt. Probeer het opnieuw.",
|
||||
"acceptedToast": "Account klaar — log nu in.",
|
||||
"adminHint": "Zoekt u het beheerderspaneel? Ga naar /admin/login.",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"passwordPlaceholder": "Your password",
|
||||
"needHelp": "Need help?",
|
||||
"poweredBy": "Powered by PicPeak"
|
||||
},
|
||||
"acceptInvite": {
|
||||
"title": "Account instellen",
|
||||
"invalidToken": "Deze uitnodigingslink is ongeldig of verlopen. Neem contact op met uw fotograaf voor een nieuwe uitnodiging.",
|
||||
"emailWillBe": "Uw account-e-mail wordt ",
|
||||
"invitedBy": ", uitgenodigd door ",
|
||||
"name": "Uw naam",
|
||||
"nameRequired": "Voer uw naam in",
|
||||
"password": "Kies een wachtwoord",
|
||||
"confirm": "Wachtwoord bevestigen",
|
||||
"passwordTooShort": "Wachtwoord moet minimaal 8 tekens bevatten",
|
||||
"passwordsMismatch": "Wachtwoorden komen niet overeen",
|
||||
"passwordHint": "Minimaal 8 tekens, met één hoofdletter en één cijfer.",
|
||||
"submit": "Account aanmaken",
|
||||
"successToast": "Account aangemaakt — log nu in.",
|
||||
"alreadyExists": "Er bestaat al een account met dit e-mailadres. Log in plaats daarvan in.",
|
||||
"invalidSubmission": "Account kon niet worden aangemaakt.",
|
||||
"generalError": "Account kon niet worden aangemaakt. Probeer het opnieuw.",
|
||||
"subtitle": "Confirm or fill in your details. You can edit anything from the profile page later.",
|
||||
"displayName": "Display name",
|
||||
"section": {
|
||||
"personal": "Personal",
|
||||
"contact": "Contact & business (optional)",
|
||||
"address": "Billing address (optional)",
|
||||
"password": "Choose a password"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Your galleries",
|
||||
"subtitle": "Click a gallery to open it. The Download button bundles every photo as a zip.",
|
||||
"loadError": "Uw galerieën konden niet worden geladen. Probeer het opnieuw.",
|
||||
"emptyTitle": "Nog geen galerieën",
|
||||
"emptyBody": "Zodra uw fotograaf u aan een galerie toewijst, verschijnt deze hier.",
|
||||
"openAria": "Galerie {{name}} openen",
|
||||
"opening": "Openen…",
|
||||
"expiresOn": "Verloopt op {{date}}",
|
||||
"expiredOn": "Verlopen op {{date}}",
|
||||
"eventExpired": "Deze galerie is verlopen.",
|
||||
"eventForbidden": "U heeft geen toegang meer tot deze galerie.",
|
||||
"openError": "Galerie kon niet worden geopend. Probeer het opnieuw.",
|
||||
"download": "Downloaden",
|
||||
"preparingDownload": "Voorbereiden…",
|
||||
"quickDownloadAria": "Alle foto's voor {{name}} downloaden",
|
||||
"downloadStarted": "Download gestart voor {{name}}",
|
||||
"downloadError": "Download kon niet worden gestart. Probeer het opnieuw.",
|
||||
"sortLabel": "Sort by",
|
||||
"sortNewest": "Newest first",
|
||||
"sortOldest": "Oldest first",
|
||||
"sortName": "By name",
|
||||
"open": "Open"
|
||||
},
|
||||
"layout": {
|
||||
"greeting": "Hallo, {{name}}"
|
||||
},
|
||||
"nav": {
|
||||
"galleries": "Galleries",
|
||||
"calendar": "Calendar",
|
||||
"quotes": "Quotes",
|
||||
"bills": "Bills",
|
||||
"profile": "Profile",
|
||||
"soon": "Soon"
|
||||
},
|
||||
"comingSoon": {
|
||||
"tag": "Coming soon"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
"body": "Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
|
||||
},
|
||||
"quotes": {
|
||||
"title": "Quotes",
|
||||
"body": "Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
|
||||
},
|
||||
"bills": {
|
||||
"title": "Bills",
|
||||
"body": "Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Customer profile",
|
||||
"subtitle": "Keep your contact and billing details up to date — they're shown on quotes and invoices once those features go live.",
|
||||
"savedToast": "Profile saved",
|
||||
"saveError": "Could not save profile.",
|
||||
"loadError": "Could not load your profile.",
|
||||
"save": "Save changes",
|
||||
"salutation": {
|
||||
"none": "— Not specified —",
|
||||
"herr": "Mr.",
|
||||
"frau": "Ms.",
|
||||
"mx": "Mx",
|
||||
"dr": "Dr."
|
||||
},
|
||||
"section": {
|
||||
"personal": "Personal information",
|
||||
"contact": "Contact & business",
|
||||
"address": "Billing address",
|
||||
"password": "Change password"
|
||||
},
|
||||
"field": {
|
||||
"email": "Email (login)",
|
||||
"emailHint": "Contact your photographer if you need to change your login email.",
|
||||
"salutation": "Salutation",
|
||||
"firstName": "First name",
|
||||
"lastName": "Last name",
|
||||
"displayName": "Display name",
|
||||
"displayNameHint": "How we greet you in the dashboard.",
|
||||
"phone": "Phone",
|
||||
"companyName": "Company name",
|
||||
"vatId": "VAT ID",
|
||||
"addressLine1": "Address line 1",
|
||||
"addressLine2": "Address line 2",
|
||||
"postalCode": "Postal code",
|
||||
"city": "City",
|
||||
"state": "State / region",
|
||||
"countryCode": "Country"
|
||||
},
|
||||
"password": {
|
||||
"current": "Current password",
|
||||
"next": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"submit": "Update password",
|
||||
"hint": "At least 8 characters with one uppercase letter and one number.",
|
||||
"currentRequired": "Enter your current password",
|
||||
"tooShort": "At least 8 characters",
|
||||
"mismatch": "Passwords do not match",
|
||||
"wrong": "Current password is incorrect",
|
||||
"savedToast": "Password updated",
|
||||
"error": "Could not change password"
|
||||
}
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset your password",
|
||||
"invalidToken": "This reset link is invalid or has expired. Please ask your photographer to send a new one.",
|
||||
"forEmail": "Setting a new password for ",
|
||||
"password": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"submit": "Update password",
|
||||
"hint": "At least 8 characters with one uppercase letter and one number.",
|
||||
"tooShort": "Password must be at least 8 characters",
|
||||
"mismatch": "Passwords do not match",
|
||||
"successToast": "Password updated. Please log in.",
|
||||
"invalidSubmission": "Could not update your password.",
|
||||
"generalError": "Could not update your password. Please try again."
|
||||
}
|
||||
},
|
||||
"customers": {
|
||||
"pageTitle": "Klanten",
|
||||
"pageSubtitle": "Terugkerende klantaccounts die kunnen inloggen op /customer/login.",
|
||||
"unnamed": "Naamloos",
|
||||
"empty": "Nog geen klanten. Klik op \"Klant uitnodigen\" om er een toe te voegen.",
|
||||
"loadError": "Klanten konden niet worden geladen",
|
||||
"loadInvitationsError": "Uitnodigingen konden niet worden geladen",
|
||||
"tabs": {
|
||||
"customers": "Klanten",
|
||||
"invitations": "Uitnodigingen"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Zoeken op e-mail, naam of bedrijf"
|
||||
},
|
||||
"table": {
|
||||
"name": "Naam",
|
||||
"email": "E-mail",
|
||||
"company": "Bedrijf",
|
||||
"eventCount": "Evenementen",
|
||||
"lastLogin": "Laatste login",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"active": "Actief",
|
||||
"inactive": "Gedeactiveerd"
|
||||
},
|
||||
"invite": {
|
||||
"button": "Klant uitnodigen",
|
||||
"title": "Een klant uitnodigen",
|
||||
"description": "Zij ontvangen een e-mail met een link om hun account in te stellen. Zodra zij hebben geaccepteerd, kunt u hen toewijzen aan evenementen.",
|
||||
"email": "E-mail",
|
||||
"submit": "Uitnodiging sturen",
|
||||
"success": "Uitnodiging verzonden",
|
||||
"error": "Uitnodiging kon niet worden verzonden.",
|
||||
"conflict": "Er bestaat al een klant met dit e-mailadres of er is een openstaande uitnodiging.",
|
||||
"invalidEmail": "Voer een geldig e-mailadres in",
|
||||
"showPrefill": "+ Add contact details (optional)",
|
||||
"hidePrefill": "− Hide contact details",
|
||||
"prefillHint": "Anything you fill in will be pre-populated on the customer's sign-up page — they can still edit it."
|
||||
},
|
||||
"invitations": {
|
||||
"empty": "Geen openstaande uitnodigingen.",
|
||||
"email": "E-mail",
|
||||
"invitedBy": "Uitgenodigd door",
|
||||
"expiresAt": "Verloopt",
|
||||
"createdAt": "Aangemaakt",
|
||||
"cancel": "Annuleren"
|
||||
},
|
||||
"deactivate": {
|
||||
"button": "Deactiveren",
|
||||
"title": "Klant deactiveren?",
|
||||
"body": "Zij kunnen niet meer inloggen. U kunt hen later opnieuw uitnodigen.",
|
||||
"success": "Klant gedeactiveerd",
|
||||
"error": "Klant kon niet worden gedeactiveerd"
|
||||
},
|
||||
"cancelInvitation": {
|
||||
"title": "Uitnodiging annuleren?",
|
||||
"body": "De uitnodigingslink stopt direct met werken.",
|
||||
"success": "Uitnodiging geannuleerd",
|
||||
"error": "Uitnodiging kon niet worden geannuleerd"
|
||||
},
|
||||
"detail": {
|
||||
"loadError": "Klant kon niet worden geladen",
|
||||
"saved": "Klant opgeslagen",
|
||||
"saveError": "Wijzigingen konden niet worden opgeslagen.",
|
||||
"emailConflict": "Dat e-mailadres is al in gebruik door een andere klant.",
|
||||
"save": "Wijzigingen opslaan",
|
||||
"expires": "verloopt",
|
||||
"accountSection": "Account",
|
||||
"personalSection": "Persoonlijke gegevens",
|
||||
"billingSection": "Adres & facturering",
|
||||
"notesSection": "Interne notities",
|
||||
"eventsSection": "Toegewezen evenementen",
|
||||
"noEvents": "Nog niet toegewezen aan evenementen. Voeg deze klant toe aan een evenement via het evenementformulier.",
|
||||
"email": "E-mail",
|
||||
"preferredLanguage": "Voorkeurstaal",
|
||||
"salutation": "Aanhef",
|
||||
"salutationNone": "—",
|
||||
"firstName": "Voornaam",
|
||||
"lastName": "Achternaam",
|
||||
"displayName": "Weergavenaam",
|
||||
"phone": "Telefoon",
|
||||
"company": "Bedrijf",
|
||||
"billingEmail": "Facturatie-e-mail",
|
||||
"vatId": "BTW / fiscaal nummer",
|
||||
"addressLine1": "Adresregel 1",
|
||||
"addressLine2": "Adresregel 2",
|
||||
"postalCode": "Postcode",
|
||||
"city": "Stad",
|
||||
"state": "Provincie / regio",
|
||||
"countryCode": "Land (ISO 2)",
|
||||
"notesHint": "Alleen zichtbaar voor beheerders. Wordt nooit aan de klant getoond.",
|
||||
"featuresSection": "Customer features",
|
||||
"featuresHint": "Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Customer Surface are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.",
|
||||
"passwordSection": "Account actions",
|
||||
"passwordHint": "Sends a 7-day single-use reset link to the customer's email. The customer's current password keeps working until they click the link and set a new one.",
|
||||
"passwordReset": {
|
||||
"button": "Send password reset email",
|
||||
"success": "Password reset email sent",
|
||||
"error": "Could not send password reset",
|
||||
"inactive": "Reactivate the customer before sending a reset."
|
||||
}
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Reactivate",
|
||||
"success": "Customer reactivated",
|
||||
"error": "Could not reactivate customer"
|
||||
},
|
||||
"erase": {
|
||||
"button": "Erase customer data",
|
||||
"title": "Erase customer data?",
|
||||
"body": "Removes the customer's name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.",
|
||||
"confirm": "Erase permanently",
|
||||
"confirmInFlight": "Erasing…",
|
||||
"success": "Customer erased",
|
||||
"error": "Could not erase customer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,8 @@
|
||||
"backup": "Backup e Restauração",
|
||||
"cmsPages": "Páginas CMS",
|
||||
"users": "Utilizadores",
|
||||
"calendar": "Calendário"
|
||||
"calendar": "Calendário",
|
||||
"customers": "Customers"
|
||||
},
|
||||
"archives": {
|
||||
"title": "Arquivos",
|
||||
@@ -2573,5 +2574,290 @@
|
||||
"movedToCategory_other": "{{count}} fotos movidas para {{category}}",
|
||||
"moveToCategoryFailed": "Falha ao mover fotos para categoria",
|
||||
"moveToCategory": "Mover para categoria"
|
||||
},
|
||||
"customer": {
|
||||
"login": {
|
||||
"title": "Login de cliente",
|
||||
"subtitle": "Acesse todas as suas galerias de fotos em um só lugar.",
|
||||
"email": "E-mail",
|
||||
"password": "Senha",
|
||||
"emailRequired": "E-mail é obrigatório",
|
||||
"invalidEmail": "Insira um e-mail válido",
|
||||
"passwordRequired": "Senha é obrigatória",
|
||||
"showPassword": "Mostrar senha",
|
||||
"hidePassword": "Ocultar senha",
|
||||
"signIn": "Entrar",
|
||||
"loginSuccess": "Bem-vindo de volta!",
|
||||
"invalidCredentials": "E-mail ou senha inválidos",
|
||||
"tooManyAttempts": "Muitas tentativas — tente novamente mais tarde.",
|
||||
"networkError": "Não foi possível conectar ao servidor. Tente novamente.",
|
||||
"generalError": "Falha no login. Tente novamente.",
|
||||
"acceptedToast": "Conta pronta — faça login.",
|
||||
"adminHint": "Procurando o painel de administração? Acesse /admin/login.",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"passwordPlaceholder": "Your password",
|
||||
"needHelp": "Need help?",
|
||||
"poweredBy": "Powered by PicPeak"
|
||||
},
|
||||
"acceptInvite": {
|
||||
"title": "Configurar sua conta",
|
||||
"invalidToken": "Este link de convite é inválido ou expirou. Entre em contato com seu fotógrafo para um novo convite.",
|
||||
"emailWillBe": "O e-mail da sua conta será ",
|
||||
"invitedBy": ", convidado por ",
|
||||
"name": "Seu nome",
|
||||
"nameRequired": "Insira seu nome",
|
||||
"password": "Escolha uma senha",
|
||||
"confirm": "Confirmar senha",
|
||||
"passwordTooShort": "A senha deve ter no mínimo 8 caracteres",
|
||||
"passwordsMismatch": "As senhas não coincidem",
|
||||
"passwordHint": "Mínimo de 8 caracteres, com uma letra maiúscula e um número.",
|
||||
"submit": "Criar conta",
|
||||
"successToast": "Conta criada — faça login.",
|
||||
"alreadyExists": "Já existe uma conta com este e-mail. Faça login.",
|
||||
"invalidSubmission": "Não foi possível criar sua conta.",
|
||||
"generalError": "Não foi possível criar sua conta. Tente novamente.",
|
||||
"subtitle": "Confirm or fill in your details. You can edit anything from the profile page later.",
|
||||
"displayName": "Display name",
|
||||
"section": {
|
||||
"personal": "Personal",
|
||||
"contact": "Contact & business (optional)",
|
||||
"address": "Billing address (optional)",
|
||||
"password": "Choose a password"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Your galleries",
|
||||
"subtitle": "Click a gallery to open it. The Download button bundles every photo as a zip.",
|
||||
"loadError": "Não foi possível carregar suas galerias. Tente novamente.",
|
||||
"emptyTitle": "Ainda não há galerias",
|
||||
"emptyBody": "Assim que seu fotógrafo atribuir você a uma galeria, ela aparecerá aqui.",
|
||||
"openAria": "Abrir galeria {{name}}",
|
||||
"opening": "Abrindo…",
|
||||
"expiresOn": "Expira em {{date}}",
|
||||
"expiredOn": "Expirou em {{date}}",
|
||||
"eventExpired": "Esta galeria expirou.",
|
||||
"eventForbidden": "Você não tem mais acesso a esta galeria.",
|
||||
"openError": "Não foi possível abrir esta galeria. Tente novamente.",
|
||||
"download": "Baixar",
|
||||
"preparingDownload": "Preparando…",
|
||||
"quickDownloadAria": "Baixar todas as fotos de {{name}}",
|
||||
"downloadStarted": "Download iniciado para {{name}}",
|
||||
"downloadError": "Não foi possível iniciar o download. Tente novamente.",
|
||||
"sortLabel": "Sort by",
|
||||
"sortNewest": "Newest first",
|
||||
"sortOldest": "Oldest first",
|
||||
"sortName": "By name",
|
||||
"open": "Open"
|
||||
},
|
||||
"layout": {
|
||||
"greeting": "Olá, {{name}}"
|
||||
},
|
||||
"nav": {
|
||||
"galleries": "Galleries",
|
||||
"calendar": "Calendar",
|
||||
"quotes": "Quotes",
|
||||
"bills": "Bills",
|
||||
"profile": "Profile",
|
||||
"soon": "Soon"
|
||||
},
|
||||
"comingSoon": {
|
||||
"tag": "Coming soon"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
"body": "Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
|
||||
},
|
||||
"quotes": {
|
||||
"title": "Quotes",
|
||||
"body": "Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
|
||||
},
|
||||
"bills": {
|
||||
"title": "Bills",
|
||||
"body": "Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Customer profile",
|
||||
"subtitle": "Keep your contact and billing details up to date — they're shown on quotes and invoices once those features go live.",
|
||||
"savedToast": "Profile saved",
|
||||
"saveError": "Could not save profile.",
|
||||
"loadError": "Could not load your profile.",
|
||||
"save": "Save changes",
|
||||
"salutation": {
|
||||
"none": "— Not specified —",
|
||||
"herr": "Mr.",
|
||||
"frau": "Ms.",
|
||||
"mx": "Mx",
|
||||
"dr": "Dr."
|
||||
},
|
||||
"section": {
|
||||
"personal": "Personal information",
|
||||
"contact": "Contact & business",
|
||||
"address": "Billing address",
|
||||
"password": "Change password"
|
||||
},
|
||||
"field": {
|
||||
"email": "Email (login)",
|
||||
"emailHint": "Contact your photographer if you need to change your login email.",
|
||||
"salutation": "Salutation",
|
||||
"firstName": "First name",
|
||||
"lastName": "Last name",
|
||||
"displayName": "Display name",
|
||||
"displayNameHint": "How we greet you in the dashboard.",
|
||||
"phone": "Phone",
|
||||
"companyName": "Company name",
|
||||
"vatId": "VAT ID",
|
||||
"addressLine1": "Address line 1",
|
||||
"addressLine2": "Address line 2",
|
||||
"postalCode": "Postal code",
|
||||
"city": "City",
|
||||
"state": "State / region",
|
||||
"countryCode": "Country"
|
||||
},
|
||||
"password": {
|
||||
"current": "Current password",
|
||||
"next": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"submit": "Update password",
|
||||
"hint": "At least 8 characters with one uppercase letter and one number.",
|
||||
"currentRequired": "Enter your current password",
|
||||
"tooShort": "At least 8 characters",
|
||||
"mismatch": "Passwords do not match",
|
||||
"wrong": "Current password is incorrect",
|
||||
"savedToast": "Password updated",
|
||||
"error": "Could not change password"
|
||||
}
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset your password",
|
||||
"invalidToken": "This reset link is invalid or has expired. Please ask your photographer to send a new one.",
|
||||
"forEmail": "Setting a new password for ",
|
||||
"password": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"submit": "Update password",
|
||||
"hint": "At least 8 characters with one uppercase letter and one number.",
|
||||
"tooShort": "Password must be at least 8 characters",
|
||||
"mismatch": "Passwords do not match",
|
||||
"successToast": "Password updated. Please log in.",
|
||||
"invalidSubmission": "Could not update your password.",
|
||||
"generalError": "Could not update your password. Please try again."
|
||||
}
|
||||
},
|
||||
"customers": {
|
||||
"pageTitle": "Clientes",
|
||||
"pageSubtitle": "Contas recorrentes de clientes que podem entrar em /customer/login.",
|
||||
"unnamed": "Sem nome",
|
||||
"empty": "Ainda não há clientes. Clique em \"Convidar cliente\" para adicionar um.",
|
||||
"loadError": "Não foi possível carregar os clientes",
|
||||
"loadInvitationsError": "Não foi possível carregar os convites",
|
||||
"tabs": {
|
||||
"customers": "Clientes",
|
||||
"invitations": "Convites"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Pesquisar por e-mail, nome ou empresa"
|
||||
},
|
||||
"table": {
|
||||
"name": "Nome",
|
||||
"email": "E-mail",
|
||||
"company": "Empresa",
|
||||
"eventCount": "Eventos",
|
||||
"lastLogin": "Último login",
|
||||
"status": "Status"
|
||||
},
|
||||
"status": {
|
||||
"active": "Ativo",
|
||||
"inactive": "Desativado"
|
||||
},
|
||||
"invite": {
|
||||
"button": "Convidar cliente",
|
||||
"title": "Convidar um cliente",
|
||||
"description": "Eles receberão um e-mail com um link para configurar a conta. Após aceitarem, você poderá atribuí-los a eventos.",
|
||||
"email": "E-mail",
|
||||
"submit": "Enviar convite",
|
||||
"success": "Convite enviado",
|
||||
"error": "Não foi possível enviar o convite.",
|
||||
"conflict": "Já existe um cliente com este e-mail ou há um convite pendente.",
|
||||
"invalidEmail": "Insira um e-mail válido",
|
||||
"showPrefill": "+ Add contact details (optional)",
|
||||
"hidePrefill": "− Hide contact details",
|
||||
"prefillHint": "Anything you fill in will be pre-populated on the customer's sign-up page — they can still edit it."
|
||||
},
|
||||
"invitations": {
|
||||
"empty": "Nenhum convite pendente.",
|
||||
"email": "E-mail",
|
||||
"invitedBy": "Convidado por",
|
||||
"expiresAt": "Expira",
|
||||
"createdAt": "Criado",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"deactivate": {
|
||||
"button": "Desativar",
|
||||
"title": "Desativar cliente?",
|
||||
"body": "Eles não poderão mais fazer login. Você pode convidá-los novamente depois.",
|
||||
"success": "Cliente desativado",
|
||||
"error": "Não foi possível desativar o cliente"
|
||||
},
|
||||
"cancelInvitation": {
|
||||
"title": "Cancelar convite?",
|
||||
"body": "O link do convite deixará de funcionar imediatamente.",
|
||||
"success": "Convite cancelado",
|
||||
"error": "Não foi possível cancelar o convite"
|
||||
},
|
||||
"detail": {
|
||||
"loadError": "Não foi possível carregar o cliente",
|
||||
"saved": "Cliente salvo",
|
||||
"saveError": "Não foi possível salvar as alterações.",
|
||||
"emailConflict": "Este e-mail já está em uso por outro cliente.",
|
||||
"save": "Salvar alterações",
|
||||
"expires": "expira",
|
||||
"accountSection": "Conta",
|
||||
"personalSection": "Informações pessoais",
|
||||
"billingSection": "Endereço & faturamento",
|
||||
"notesSection": "Notas internas",
|
||||
"eventsSection": "Eventos atribuídos",
|
||||
"noEvents": "Ainda não atribuído a nenhum evento. Adicione este cliente a um evento pelo formulário de evento.",
|
||||
"email": "E-mail",
|
||||
"preferredLanguage": "Idioma preferido",
|
||||
"salutation": "Saudação",
|
||||
"salutationNone": "—",
|
||||
"firstName": "Nome",
|
||||
"lastName": "Sobrenome",
|
||||
"displayName": "Nome de exibição",
|
||||
"phone": "Telefone",
|
||||
"company": "Empresa",
|
||||
"billingEmail": "E-mail de faturamento",
|
||||
"vatId": "NIF / CNPJ",
|
||||
"addressLine1": "Endereço linha 1",
|
||||
"addressLine2": "Endereço linha 2",
|
||||
"postalCode": "CEP",
|
||||
"city": "Cidade",
|
||||
"state": "Estado / região",
|
||||
"countryCode": "País (ISO 2)",
|
||||
"notesHint": "Visível apenas para administradores. Nunca mostrado ao cliente.",
|
||||
"featuresSection": "Customer features",
|
||||
"featuresHint": "Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Customer Surface are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.",
|
||||
"passwordSection": "Account actions",
|
||||
"passwordHint": "Sends a 7-day single-use reset link to the customer's email. The customer's current password keeps working until they click the link and set a new one.",
|
||||
"passwordReset": {
|
||||
"button": "Send password reset email",
|
||||
"success": "Password reset email sent",
|
||||
"error": "Could not send password reset",
|
||||
"inactive": "Reactivate the customer before sending a reset."
|
||||
}
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Reactivate",
|
||||
"success": "Customer reactivated",
|
||||
"error": "Could not reactivate customer"
|
||||
},
|
||||
"erase": {
|
||||
"button": "Erase customer data",
|
||||
"title": "Erase customer data?",
|
||||
"body": "Removes the customer's name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.",
|
||||
"confirm": "Erase permanently",
|
||||
"confirmInFlight": "Erasing…",
|
||||
"success": "Customer erased",
|
||||
"error": "Could not erase customer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,8 @@
|
||||
"backup": "Резервное копирование",
|
||||
"cmsPages": "CMS-страницы",
|
||||
"users": "Пользователи",
|
||||
"calendar": "Календарь"
|
||||
"calendar": "Календарь",
|
||||
"customers": "Customers"
|
||||
},
|
||||
"archives": {
|
||||
"title": "Архивы",
|
||||
@@ -2606,5 +2607,290 @@
|
||||
"movedToCategory_other": "{{count}} фото перемещено в {{category}}",
|
||||
"moveToCategoryFailed": "Не удалось переместить фото в категорию",
|
||||
"moveToCategory": "Переместить в категорию"
|
||||
},
|
||||
"customer": {
|
||||
"login": {
|
||||
"title": "Вход клиента",
|
||||
"subtitle": "Доступ ко всем вашим фотогалереям в одном месте.",
|
||||
"email": "E-mail",
|
||||
"password": "Пароль",
|
||||
"emailRequired": "E-mail обязателен",
|
||||
"invalidEmail": "Введите корректный e-mail",
|
||||
"passwordRequired": "Пароль обязателен",
|
||||
"showPassword": "Показать пароль",
|
||||
"hidePassword": "Скрыть пароль",
|
||||
"signIn": "Войти",
|
||||
"loginSuccess": "С возвращением!",
|
||||
"invalidCredentials": "Неверный e-mail или пароль",
|
||||
"tooManyAttempts": "Слишком много попыток — попробуйте позже.",
|
||||
"networkError": "Сервер недоступен. Попробуйте ещё раз.",
|
||||
"generalError": "Ошибка входа. Попробуйте ещё раз.",
|
||||
"acceptedToast": "Аккаунт готов — войдите.",
|
||||
"adminHint": "Ищете админ-панель? Перейдите на /admin/login.",
|
||||
"emailPlaceholder": "[email protected]",
|
||||
"passwordPlaceholder": "Your password",
|
||||
"needHelp": "Need help?",
|
||||
"poweredBy": "Powered by PicPeak"
|
||||
},
|
||||
"acceptInvite": {
|
||||
"title": "Настройте ваш аккаунт",
|
||||
"invalidToken": "Эта ссылка-приглашение недействительна или истекла. Свяжитесь с фотографом для нового приглашения.",
|
||||
"emailWillBe": "E-mail вашего аккаунта будет ",
|
||||
"invitedBy": ", пригласил ",
|
||||
"name": "Ваше имя",
|
||||
"nameRequired": "Введите ваше имя",
|
||||
"password": "Выберите пароль",
|
||||
"confirm": "Подтвердите пароль",
|
||||
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
|
||||
"passwordsMismatch": "Пароли не совпадают",
|
||||
"passwordHint": "Не менее 8 символов, с одной заглавной буквой и одной цифрой.",
|
||||
"submit": "Создать аккаунт",
|
||||
"successToast": "Аккаунт создан — войдите.",
|
||||
"alreadyExists": "Аккаунт с таким e-mail уже существует. Войдите вместо этого.",
|
||||
"invalidSubmission": "Не удалось создать аккаунт.",
|
||||
"generalError": "Не удалось создать аккаунт. Попробуйте ещё раз.",
|
||||
"subtitle": "Confirm or fill in your details. You can edit anything from the profile page later.",
|
||||
"displayName": "Display name",
|
||||
"section": {
|
||||
"personal": "Personal",
|
||||
"contact": "Contact & business (optional)",
|
||||
"address": "Billing address (optional)",
|
||||
"password": "Choose a password"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Your galleries",
|
||||
"subtitle": "Click a gallery to open it. The Download button bundles every photo as a zip.",
|
||||
"loadError": "Не удалось загрузить ваши галереи. Попробуйте ещё раз.",
|
||||
"emptyTitle": "Пока нет галерей",
|
||||
"emptyBody": "Как только фотограф привяжет вас к галерее, она появится здесь.",
|
||||
"openAria": "Открыть галерею {{name}}",
|
||||
"opening": "Открывается…",
|
||||
"expiresOn": "Истекает {{date}}",
|
||||
"expiredOn": "Истёк {{date}}",
|
||||
"eventExpired": "Эта галерея истекла.",
|
||||
"eventForbidden": "У вас больше нет доступа к этой галерее.",
|
||||
"openError": "Не удалось открыть эту галерею. Попробуйте ещё раз.",
|
||||
"download": "Скачать",
|
||||
"preparingDownload": "Подготовка…",
|
||||
"quickDownloadAria": "Скачать все фото для {{name}}",
|
||||
"downloadStarted": "Скачивание начато для {{name}}",
|
||||
"downloadError": "Не удалось начать скачивание. Попробуйте ещё раз.",
|
||||
"sortLabel": "Sort by",
|
||||
"sortNewest": "Newest first",
|
||||
"sortOldest": "Oldest first",
|
||||
"sortName": "By name",
|
||||
"open": "Open"
|
||||
},
|
||||
"layout": {
|
||||
"greeting": "Привет, {{name}}"
|
||||
},
|
||||
"nav": {
|
||||
"galleries": "Galleries",
|
||||
"calendar": "Calendar",
|
||||
"quotes": "Quotes",
|
||||
"bills": "Bills",
|
||||
"profile": "Profile",
|
||||
"soon": "Soon"
|
||||
},
|
||||
"comingSoon": {
|
||||
"tag": "Coming soon"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
"body": "Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
|
||||
},
|
||||
"quotes": {
|
||||
"title": "Quotes",
|
||||
"body": "Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
|
||||
},
|
||||
"bills": {
|
||||
"title": "Bills",
|
||||
"body": "Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
|
||||
},
|
||||
"profile": {
|
||||
"title": "Customer profile",
|
||||
"subtitle": "Keep your contact and billing details up to date — they're shown on quotes and invoices once those features go live.",
|
||||
"savedToast": "Profile saved",
|
||||
"saveError": "Could not save profile.",
|
||||
"loadError": "Could not load your profile.",
|
||||
"save": "Save changes",
|
||||
"salutation": {
|
||||
"none": "— Not specified —",
|
||||
"herr": "Mr.",
|
||||
"frau": "Ms.",
|
||||
"mx": "Mx",
|
||||
"dr": "Dr."
|
||||
},
|
||||
"section": {
|
||||
"personal": "Personal information",
|
||||
"contact": "Contact & business",
|
||||
"address": "Billing address",
|
||||
"password": "Change password"
|
||||
},
|
||||
"field": {
|
||||
"email": "Email (login)",
|
||||
"emailHint": "Contact your photographer if you need to change your login email.",
|
||||
"salutation": "Salutation",
|
||||
"firstName": "First name",
|
||||
"lastName": "Last name",
|
||||
"displayName": "Display name",
|
||||
"displayNameHint": "How we greet you in the dashboard.",
|
||||
"phone": "Phone",
|
||||
"companyName": "Company name",
|
||||
"vatId": "VAT ID",
|
||||
"addressLine1": "Address line 1",
|
||||
"addressLine2": "Address line 2",
|
||||
"postalCode": "Postal code",
|
||||
"city": "City",
|
||||
"state": "State / region",
|
||||
"countryCode": "Country"
|
||||
},
|
||||
"password": {
|
||||
"current": "Current password",
|
||||
"next": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"submit": "Update password",
|
||||
"hint": "At least 8 characters with one uppercase letter and one number.",
|
||||
"currentRequired": "Enter your current password",
|
||||
"tooShort": "At least 8 characters",
|
||||
"mismatch": "Passwords do not match",
|
||||
"wrong": "Current password is incorrect",
|
||||
"savedToast": "Password updated",
|
||||
"error": "Could not change password"
|
||||
}
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Reset your password",
|
||||
"invalidToken": "This reset link is invalid or has expired. Please ask your photographer to send a new one.",
|
||||
"forEmail": "Setting a new password for ",
|
||||
"password": "New password",
|
||||
"confirm": "Confirm new password",
|
||||
"submit": "Update password",
|
||||
"hint": "At least 8 characters with one uppercase letter and one number.",
|
||||
"tooShort": "Password must be at least 8 characters",
|
||||
"mismatch": "Passwords do not match",
|
||||
"successToast": "Password updated. Please log in.",
|
||||
"invalidSubmission": "Could not update your password.",
|
||||
"generalError": "Could not update your password. Please try again."
|
||||
}
|
||||
},
|
||||
"customers": {
|
||||
"pageTitle": "Клиенты",
|
||||
"pageSubtitle": "Повторные клиентские аккаунты, которые могут входить на /customer/login.",
|
||||
"unnamed": "Без имени",
|
||||
"empty": "Пока нет клиентов. Нажмите «Пригласить клиента», чтобы добавить.",
|
||||
"loadError": "Не удалось загрузить клиентов",
|
||||
"loadInvitationsError": "Не удалось загрузить приглашения",
|
||||
"tabs": {
|
||||
"customers": "Клиенты",
|
||||
"invitations": "Приглашения"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Поиск по e-mail, имени или компании"
|
||||
},
|
||||
"table": {
|
||||
"name": "Имя",
|
||||
"email": "E-mail",
|
||||
"company": "Компания",
|
||||
"eventCount": "События",
|
||||
"lastLogin": "Последний вход",
|
||||
"status": "Статус"
|
||||
},
|
||||
"status": {
|
||||
"active": "Активен",
|
||||
"inactive": "Отключён"
|
||||
},
|
||||
"invite": {
|
||||
"button": "Пригласить клиента",
|
||||
"title": "Пригласить клиента",
|
||||
"description": "Они получат e-mail со ссылкой для настройки аккаунта. После принятия их можно назначать на события.",
|
||||
"email": "E-mail",
|
||||
"submit": "Отправить приглашение",
|
||||
"success": "Приглашение отправлено",
|
||||
"error": "Не удалось отправить приглашение.",
|
||||
"conflict": "Клиент с таким e-mail уже существует или имеет ожидающее приглашение.",
|
||||
"invalidEmail": "Введите корректный e-mail",
|
||||
"showPrefill": "+ Add contact details (optional)",
|
||||
"hidePrefill": "− Hide contact details",
|
||||
"prefillHint": "Anything you fill in will be pre-populated on the customer's sign-up page — they can still edit it."
|
||||
},
|
||||
"invitations": {
|
||||
"empty": "Нет ожидающих приглашений.",
|
||||
"email": "E-mail",
|
||||
"invitedBy": "Пригласил",
|
||||
"expiresAt": "Истекает",
|
||||
"createdAt": "Создано",
|
||||
"cancel": "Отмена"
|
||||
},
|
||||
"deactivate": {
|
||||
"button": "Деактивировать",
|
||||
"title": "Деактивировать клиента?",
|
||||
"body": "Они больше не смогут войти. Вы можете пригласить их повторно позже.",
|
||||
"success": "Клиент деактивирован",
|
||||
"error": "Не удалось деактивировать клиента"
|
||||
},
|
||||
"cancelInvitation": {
|
||||
"title": "Отменить приглашение?",
|
||||
"body": "Ссылка-приглашение перестанет работать немедленно.",
|
||||
"success": "Приглашение отменено",
|
||||
"error": "Не удалось отменить приглашение"
|
||||
},
|
||||
"detail": {
|
||||
"loadError": "Не удалось загрузить клиента",
|
||||
"saved": "Клиент сохранён",
|
||||
"saveError": "Не удалось сохранить изменения.",
|
||||
"emailConflict": "Этот e-mail уже используется другим клиентом.",
|
||||
"save": "Сохранить изменения",
|
||||
"expires": "истекает",
|
||||
"accountSection": "Аккаунт",
|
||||
"personalSection": "Личная информация",
|
||||
"billingSection": "Адрес и биллинг",
|
||||
"notesSection": "Внутренние заметки",
|
||||
"eventsSection": "Назначенные события",
|
||||
"noEvents": "Пока не привязан ни к одному событию. Добавьте этого клиента в форме события.",
|
||||
"email": "E-mail",
|
||||
"preferredLanguage": "Предпочтительный язык",
|
||||
"salutation": "Обращение",
|
||||
"salutationNone": "—",
|
||||
"firstName": "Имя",
|
||||
"lastName": "Фамилия",
|
||||
"displayName": "Отображаемое имя",
|
||||
"phone": "Телефон",
|
||||
"company": "Компания",
|
||||
"billingEmail": "E-mail для счетов",
|
||||
"vatId": "НДС / ИНН",
|
||||
"addressLine1": "Адрес, строка 1",
|
||||
"addressLine2": "Адрес, строка 2",
|
||||
"postalCode": "Почтовый индекс",
|
||||
"city": "Город",
|
||||
"state": "Область / регион",
|
||||
"countryCode": "Страна (ISO 2)",
|
||||
"notesHint": "Видно только администраторам. Никогда не показывается клиенту.",
|
||||
"featuresSection": "Customer features",
|
||||
"featuresHint": "Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Customer Surface are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.",
|
||||
"passwordSection": "Account actions",
|
||||
"passwordHint": "Sends a 7-day single-use reset link to the customer's email. The customer's current password keeps working until they click the link and set a new one.",
|
||||
"passwordReset": {
|
||||
"button": "Send password reset email",
|
||||
"success": "Password reset email sent",
|
||||
"error": "Could not send password reset",
|
||||
"inactive": "Reactivate the customer before sending a reset."
|
||||
}
|
||||
},
|
||||
"reactivate": {
|
||||
"button": "Reactivate",
|
||||
"success": "Customer reactivated",
|
||||
"error": "Could not reactivate customer"
|
||||
},
|
||||
"erase": {
|
||||
"button": "Erase customer data",
|
||||
"title": "Erase customer data?",
|
||||
"body": "Removes the customer's name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.",
|
||||
"confirm": "Erase permanently",
|
||||
"confirmInFlight": "Erasing…",
|
||||
"success": "Customer erased",
|
||||
"error": "Could not erase customer"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,6 +416,46 @@
|
||||
@apply text-neutral-500;
|
||||
}
|
||||
|
||||
/*
|
||||
* Customer-surface scope (#354): the default .input and .card classes
|
||||
* hard-code bg-white, and the customer surface uses theme variables
|
||||
* rather than the admin's `.dark` class trigger. Overriding here so
|
||||
* every <Input> and <Card> rendered inside the customer surface picks
|
||||
* up var(--color-surface) / var(--color-text) automatically — no
|
||||
* per-page wrapper needed. Same treatment for native <select>
|
||||
* elements which share the .input mental model on the profile and
|
||||
* accept-invite forms.
|
||||
*
|
||||
* The .customer-surface marker is set on the root <div> of every
|
||||
* customer page (CustomerLayout, CustomerLoginPage, CustomerAcceptInvitePage,
|
||||
* CustomerResetPasswordPage). Pages outside this scope (admin, public
|
||||
* gallery) keep their original styling.
|
||||
*/
|
||||
.customer-surface .card,
|
||||
.customer-surface .card-hover {
|
||||
background-color: var(--color-surface);
|
||||
border-color: var(--color-surface-border);
|
||||
}
|
||||
|
||||
.customer-surface .input,
|
||||
.customer-surface select.input,
|
||||
.customer-surface input.input {
|
||||
background-color: var(--color-surface);
|
||||
border-color: var(--color-surface-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.customer-surface .input::placeholder {
|
||||
color: var(--color-muted-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.customer-surface .input:disabled {
|
||||
background-color: var(--color-elevated, var(--color-surface));
|
||||
color: var(--color-muted-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.dark .input-themed {
|
||||
@apply bg-neutral-800 border-neutral-700 text-neutral-100;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, PasswordGenerator } from '../../components/common';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
|
||||
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
@@ -68,6 +69,10 @@ interface FormData {
|
||||
client_password: string;
|
||||
// Default photo sort
|
||||
default_photo_sort: string;
|
||||
// Customer accounts assigned to this event (#354). The state holds
|
||||
// the full picker selection so chips render without an extra fetch;
|
||||
// only the ids are sent to the backend on submit.
|
||||
customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
|
||||
}
|
||||
|
||||
// Fallback event types (used when API is unavailable)
|
||||
@@ -127,6 +132,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
client_access_enabled: false,
|
||||
client_password: '',
|
||||
default_photo_sort: 'upload_date_desc',
|
||||
customer_accounts: [],
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
|
||||
@@ -437,6 +443,10 @@ export const CreateEventPage: React.FC = () => {
|
||||
client_password: formData.client_access_enabled ? formData.client_password : undefined,
|
||||
// Default photo sort
|
||||
default_photo_sort: formData.default_photo_sort,
|
||||
// Customer accounts assigned to this event (#354). Sent as a flat
|
||||
// array of ids; the backend service diffs against the existing
|
||||
// assignments and applies adds/removes inside one transaction.
|
||||
customer_account_ids: formData.customer_accounts.map((c) => c.id),
|
||||
};
|
||||
|
||||
createMutation.mutate(payload);
|
||||
@@ -755,6 +765,15 @@ export const CreateEventPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Customer accounts (#354). The picker is decoupled from
|
||||
the freeform customer_name / customer_email fields above
|
||||
— those stay as the event's primary contact while
|
||||
customer_account_ids drives login-level access. */}
|
||||
<CustomerAccountPicker
|
||||
value={formData.customer_accounts}
|
||||
onChange={(next) => setFormData((prev) => ({ ...prev, customer_accounts: next }))}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
label={requireAdminEmail ? t('events.adminEmail') : `${t('events.adminEmail')} (${t('common.optional')})`}
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* Admin → Customer detail / edit (#354).
|
||||
*
|
||||
* Mounted at /admin/customers/:id. Editable view of every field on the
|
||||
* customer_accounts table — name, salutation, address, billing, notes —
|
||||
* so an admin can keep the record current for future quotes/invoicing
|
||||
* features. Also lists the events the customer is currently assigned to
|
||||
* (linked to the event detail page; assignments themselves are managed
|
||||
* from the event form, not here).
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
|
||||
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft,
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import {
|
||||
customerAdminService,
|
||||
type CustomerAccountDetail,
|
||||
} from '../../services/customerAdmin.service';
|
||||
|
||||
type EditableFields =
|
||||
| 'email' | 'salutation' | 'firstName' | 'lastName' | 'displayName'
|
||||
| 'phone' | 'companyName' | 'billingEmail' | 'vatId'
|
||||
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
|
||||
| 'countryCode' | 'preferredLanguage' | 'notes'
|
||||
| 'featureCalendar' | 'featureQuotes' | 'featureBills';
|
||||
|
||||
const formatDate = (iso: string | null | undefined) => {
|
||||
if (!iso) return '—';
|
||||
try { return format(new Date(iso), 'PP'); } catch { return '—'; }
|
||||
};
|
||||
|
||||
export const CustomerDetailPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const customerId = Number(id);
|
||||
|
||||
const { data: customer, isLoading, error } = useQuery({
|
||||
queryKey: ['admin-customer', customerId],
|
||||
queryFn: () => customerAdminService.get(customerId),
|
||||
enabled: Number.isFinite(customerId) && customerId > 0,
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({});
|
||||
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
|
||||
const [confirmErase, setConfirmErase] = useState(false);
|
||||
|
||||
// Hydrate the form from the fetched record once. We deliberately do NOT
|
||||
// re-sync on every refetch so an admin's in-progress edits aren't blown
|
||||
// away by a background refresh.
|
||||
useEffect(() => {
|
||||
if (customer && Object.keys(form).length === 0) {
|
||||
setForm({
|
||||
email: customer.email,
|
||||
salutation: customer.salutation,
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
displayName: customer.displayName,
|
||||
phone: customer.phone,
|
||||
companyName: customer.companyName,
|
||||
billingEmail: customer.billingEmail,
|
||||
vatId: customer.vatId,
|
||||
addressLine1: customer.addressLine1,
|
||||
addressLine2: customer.addressLine2,
|
||||
postalCode: customer.postalCode,
|
||||
city: customer.city,
|
||||
state: customer.state,
|
||||
countryCode: customer.countryCode,
|
||||
preferredLanguage: customer.preferredLanguage,
|
||||
notes: customer.notes,
|
||||
featureCalendar: customer.featureCalendar ?? false,
|
||||
featureQuotes: customer.featureQuotes ?? false,
|
||||
featureBills: customer.featureBills ?? false,
|
||||
} as any);
|
||||
}
|
||||
}, [customer, form]);
|
||||
|
||||
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills') => {
|
||||
setForm((prev) => ({ ...prev, [key]: !prev[key] }) as any);
|
||||
};
|
||||
|
||||
const setField = (key: EditableFields) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
||||
setForm((prev) => ({ ...prev, [key]: e.target.value }));
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.update(customerId, form),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData(['admin-customer', customerId], updated);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
toast.success(t('customers.detail.saved', 'Customer saved'));
|
||||
},
|
||||
onError: (e: any) => {
|
||||
const msg = e?.response?.status === 409
|
||||
? t('customers.detail.emailConflict', 'That email is already in use by another customer.')
|
||||
: e?.response?.data?.error || t('customers.detail.saveError', 'Could not save changes.');
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Trigger a password-reset email. Reused permission `customers.create`
|
||||
* server-side because issuing a reset is the same authority level as
|
||||
* issuing an invitation (both put a credential in the customer's mailbox).
|
||||
* Confirm dialog ahead of the click is surfaced via the same modal
|
||||
* pattern as deactivate.
|
||||
*/
|
||||
const passwordResetMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.sendPasswordReset(customerId),
|
||||
onSuccess: () => toast.success(t('customers.detail.passwordReset.success', 'Password reset email sent')),
|
||||
onError: () => toast.error(t('customers.detail.passwordReset.error', 'Could not send password reset')),
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.deactivate(customerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
toast.success(t('customers.deactivate.success', 'Customer deactivated'));
|
||||
navigate('/admin/customers');
|
||||
},
|
||||
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
|
||||
});
|
||||
|
||||
/** Re-enable login for a deactivated customer. */
|
||||
const reactivateMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.reactivate(customerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
toast.success(t('customers.reactivate.success', 'Customer reactivated'));
|
||||
},
|
||||
onError: () => toast.error(t('customers.reactivate.error', 'Could not reactivate customer')),
|
||||
});
|
||||
|
||||
/**
|
||||
* Anonymize-in-place erasure. Two-step UX: requires the customer to be
|
||||
* deactivated first, then a separate confirm modal. Hard delete is
|
||||
* deliberately NOT exposed — see service notes for why.
|
||||
*/
|
||||
const eraseMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.erase(customerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
toast.success(t('customers.erase.success', 'Customer erased'));
|
||||
navigate('/admin/customers');
|
||||
},
|
||||
onError: () => toast.error(t('customers.erase.error', 'Could not erase customer')),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex justify-center py-16"><Loading /></div>;
|
||||
}
|
||||
if (error || !customer) {
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<div className="text-sm text-red-600 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t('customers.detail.loadError', 'Could not load customer')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-6 space-y-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link
|
||||
to="/admin/customers"
|
||||
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
aria-label={t('common.back', 'Back')}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-muted-theme" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-bold text-theme truncate">
|
||||
{customer.displayName || customer.email}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-theme truncate">{customer.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{customer.isActive ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs" style={{ color: 'var(--color-accent)' }}>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{t('customers.status.active', 'Active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-red-600">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t('customers.status.inactive', 'Deactivated')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account section */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" /> {t('customers.detail.accountSection', 'Account')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.email', 'Email')}</label>
|
||||
<Input type="email" value={form.email || ''} onChange={setField('email')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.preferredLanguage', 'Preferred language')}</label>
|
||||
<select
|
||||
value={form.preferredLanguage || 'en'}
|
||||
onChange={setField('preferredLanguage')}
|
||||
className="input"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Personal section */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4">
|
||||
{t('customers.detail.personalSection', 'Personal information')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.salutation', 'Salutation')}</label>
|
||||
{/* Salutation values are stored verbatim in the DB ("Herr",
|
||||
"Frau", "Mx", "Dr") — those are the canonical token values
|
||||
across locales. Display labels are translated; the value
|
||||
attribute stays in the German form so existing rows
|
||||
remain valid regardless of which locale the admin is
|
||||
viewing the dropdown in. */}
|
||||
<select
|
||||
value={form.salutation || ''}
|
||||
onChange={setField('salutation')}
|
||||
className="input"
|
||||
>
|
||||
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
|
||||
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
|
||||
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
|
||||
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
|
||||
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.firstName', 'First name')}</label>
|
||||
<Input value={form.firstName || ''} onChange={setField('firstName')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.lastName', 'Last name')}</label>
|
||||
<Input value={form.lastName || ''} onChange={setField('lastName')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.displayName', 'Display name')}</label>
|
||||
<Input value={form.displayName || ''} onChange={setField('displayName')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1 flex items-center gap-1">
|
||||
<Phone className="w-4 h-4" /> {t('customers.detail.phone', 'Phone')}
|
||||
</label>
|
||||
<Input value={form.phone || ''} onChange={setField('phone')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1 flex items-center gap-1">
|
||||
<Building2 className="w-4 h-4" /> {t('customers.detail.company', 'Company')}
|
||||
</label>
|
||||
<Input value={form.companyName || ''} onChange={setField('companyName')} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Address + billing */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" /> {t('customers.detail.billingSection', 'Address & billing')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.billingEmail', 'Billing email')}</label>
|
||||
<Input type="email" value={form.billingEmail || ''} onChange={setField('billingEmail')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.vatId', 'VAT / tax ID')}</label>
|
||||
<Input value={form.vatId || ''} onChange={setField('vatId')} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.addressLine1', 'Address line 1')}</label>
|
||||
<Input value={form.addressLine1 || ''} onChange={setField('addressLine1')} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.addressLine2', 'Address line 2')}</label>
|
||||
<Input value={form.addressLine2 || ''} onChange={setField('addressLine2')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.postalCode', 'Postal code')}</label>
|
||||
<Input value={form.postalCode || ''} onChange={setField('postalCode')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.city', 'City')}</label>
|
||||
<Input value={form.city || ''} onChange={setField('city')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.state', 'State / region')}</label>
|
||||
<Input value={form.state || ''} onChange={setField('state')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country (ISO 2)')}</label>
|
||||
<Input
|
||||
value={form.countryCode || ''}
|
||||
onChange={setField('countryCode')}
|
||||
maxLength={2}
|
||||
placeholder="e.g. CH"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Per-customer feature flags (#354 follow-up) */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
{t('customers.detail.featuresSection', 'Customer features')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-4">
|
||||
{t(
|
||||
'customers.detail.featuresHint',
|
||||
'Per-customer overrides for the customer-surface tabs. The global toggles in Settings → Features are the master switch — when global is OFF nobody sees the tab, regardless of what you set here. Defaults are ON, so flip a switch OFF to hide a tab for this specific customer.'
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{([
|
||||
{ key: 'featureCalendar', labelKey: 'customer.nav.calendar', fallback: 'Calendar' },
|
||||
{ key: 'featureQuotes', labelKey: 'customer.nav.quotes', fallback: 'Quotes' },
|
||||
{ key: 'featureBills', labelKey: 'customer.nav.bills', fallback: 'Bills' },
|
||||
] as const).map(({ key, labelKey, fallback }) => {
|
||||
const enabled = !!form[key];
|
||||
return (
|
||||
<label key={key} className="flex items-center justify-between gap-3 cursor-pointer">
|
||||
<span className="text-sm font-medium text-theme flex items-center gap-2">
|
||||
{t(labelKey, fallback)}
|
||||
{/* Soon badge — these tabs are still coming-soon stubs;
|
||||
this keeps the admin honest when looking at the
|
||||
toggles. */}
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
>
|
||||
{t('customer.nav.soon', 'Soon')}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={() => toggleFeature(key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{ backgroundColor: enabled ? 'var(--color-accent)' : 'var(--color-surface-border)' }}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Account actions: password reset (#354 follow-up) */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5" />
|
||||
{t('customers.detail.passwordSection', 'Account actions')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-4">
|
||||
{t(
|
||||
'customers.detail.passwordHint',
|
||||
'Sends a 7-day single-use reset link to the customer\'s email. The customer\'s current password keeps working until they click the link and set a new one.'
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<KeyRound className="w-4 h-4" />}
|
||||
isLoading={passwordResetMutation.isPending}
|
||||
disabled={!customer.isActive}
|
||||
onClick={() => passwordResetMutation.mutate()}
|
||||
>
|
||||
{t('customers.detail.passwordReset.button', 'Send password reset email')}
|
||||
</Button>
|
||||
{!customer.isActive && (
|
||||
<p className="text-xs text-muted-theme mt-2">
|
||||
{t('customers.detail.passwordReset.inactive', 'Reactivate the customer before sending a reset.')}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Notes (admin-only) */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> {t('customers.detail.notesSection', 'Internal notes')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-theme mb-3">
|
||||
{t('customers.detail.notesHint', 'Visible only to admins. Never shown to the customer.')}
|
||||
</p>
|
||||
<textarea
|
||||
value={form.notes || ''}
|
||||
onChange={setField('notes') as any}
|
||||
rows={4}
|
||||
className="input w-full"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Assigned events */}
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-4 flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" /> {t('customers.detail.eventsSection', 'Assigned events')}
|
||||
</h2>
|
||||
{customer.events.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t('customers.detail.noEvents', 'Not assigned to any events yet. Add this customer to an event from the event form.')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
{customer.events.map((ev) => (
|
||||
<li key={ev.id} className="py-2 flex items-center justify-between">
|
||||
<Link to={`/admin/events/${ev.id}`} className="text-theme hover:underline">
|
||||
{ev.eventName}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme">
|
||||
{ev.eventDate ? formatDate(ev.eventDate) : ''}
|
||||
{ev.expiresAt ? ` · ${t('customers.detail.expires', 'expires')} ${formatDate(ev.expiresAt)}` : ''}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{customer.isActive ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
onClick={() => setConfirmDeactivate(true)}
|
||||
>
|
||||
{t('customers.deactivate.button', 'Deactivate')}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<CheckCircle2 className="w-4 h-4" />}
|
||||
isLoading={reactivateMutation.isPending}
|
||||
onClick={() => reactivateMutation.mutate()}
|
||||
>
|
||||
{t('customers.reactivate.button', 'Reactivate')}
|
||||
</Button>
|
||||
{/* Erase is only offered when the customer is already
|
||||
inactive — forces a deliberate two-step (deactivate
|
||||
→ erase) and removes the chance of misclicking through
|
||||
the deactivate button on a live account. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Trash2 className="w-4 h-4 text-red-600" />}
|
||||
onClick={() => setConfirmErase(true)}
|
||||
>
|
||||
<span className="text-red-600">
|
||||
{t('customers.erase.button', 'Erase customer data')}
|
||||
</span>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Save className="w-4 h-4" />}
|
||||
isLoading={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
>
|
||||
{t('customers.detail.save', 'Save changes')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{confirmDeactivate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
{t('customers.deactivate.title', 'Deactivate customer?')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-theme">
|
||||
{t('customers.deactivate.body',
|
||||
'They will no longer be able to log in. You can re-activate or fully erase them later.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setConfirmDeactivate(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isLoading={deactivateMutation.isPending}
|
||||
onClick={() => { deactivateMutation.mutate(); setConfirmDeactivate(false); }}
|
||||
>
|
||||
{t('common.confirm', 'Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Erase confirm modal — second step after deactivate. Spelled out
|
||||
"irreversible" copy + red Confirm button so the click feels
|
||||
deliberate. The action anonymizes PII in place; assignments
|
||||
and audit-log references are preserved. */}
|
||||
{confirmErase && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 text-red-600" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
{t('customers.erase.title', 'Erase customer data?')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-theme">
|
||||
{t('customers.erase.body',
|
||||
'Removes the customer\'s name, email, phone, address, company and credentials. The account row stays so historical event-access records and audit logs still reference it. This is irreversible — you cannot restore the data afterwards.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setConfirmErase(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={eraseMutation.isPending}
|
||||
onClick={() => { eraseMutation.mutate(); setConfirmErase(false); }}
|
||||
>
|
||||
{eraseMutation.isPending
|
||||
? t('customers.erase.confirmInFlight', 'Erasing…')
|
||||
: t('customers.erase.confirm', 'Erase permanently')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerDetailPage;
|
||||
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* Admin → Customer accounts management (#354).
|
||||
*
|
||||
* Mounted at /admin/customers. Listed in AdminSidebar gated on
|
||||
* `customers.view` so only super_admin / admin see it.
|
||||
*
|
||||
* NOT a duplicate of UserManagementPage:
|
||||
* - admin_users table (admin RBAC, token type 'admin', /admin/login)
|
||||
* - customer_accounts table (per-event access, token type 'customer', /customer/login)
|
||||
*
|
||||
* The two pages share visual patterns (tabbed list + invite modal) but
|
||||
* operate on completely different DB tables, services, auth surfaces,
|
||||
* and permission models. The customer invite intentionally has no role
|
||||
* picker (customers don't have roles — access is boolean per event,
|
||||
* managed via the event form's CustomerAccountPicker).
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
UserPlus, Mail, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import {
|
||||
customerAdminService,
|
||||
type CustomerAccountSummary,
|
||||
type CustomerInvitationSummary,
|
||||
} from '../../services/customerAdmin.service';
|
||||
|
||||
type TabType = 'customers' | 'invitations';
|
||||
|
||||
const formatDate = (iso: string | null | undefined) => {
|
||||
if (!iso) return '—';
|
||||
try { return format(new Date(iso), 'PP'); } catch { return '—'; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Invite modal with optional prefill (#354 follow-up).
|
||||
*
|
||||
* Email is the only required field. Everything else is collected behind
|
||||
* a "Add contact details" toggle so a fast invite stays one-click. When
|
||||
* filled, the values are stashed on the invitation row and re-rendered
|
||||
* pre-populated on the customer's accept page (where the customer can
|
||||
* still edit before submitting).
|
||||
*/
|
||||
const InviteModal: React.FC<{
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onInvited: () => void;
|
||||
}> = ({ isOpen, onClose, onInvited }) => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showPrefill, setShowPrefill] = useState(false);
|
||||
const [prefill, setPrefill] = useState({
|
||||
salutation: '', first_name: '', last_name: '', display_name: '',
|
||||
phone: '', company_name: '', vat_id: '',
|
||||
address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
|
||||
});
|
||||
|
||||
const updatePrefill = (key: keyof typeof prefill, value: string) => {
|
||||
setPrefill((p) => ({ ...p, [key]: value }));
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setEmail('');
|
||||
setError(null);
|
||||
setShowPrefill(false);
|
||||
setPrefill({
|
||||
salutation: '', first_name: '', last_name: '', display_name: '',
|
||||
phone: '', company_name: '', vat_id: '',
|
||||
address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError(t('customers.invite.invalidEmail', 'Please enter a valid email'));
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Strip empty values so the backend stores `null`/nothing for fields
|
||||
// the admin didn't actually fill in. Saves a round trip through
|
||||
// the backend's sanitiser and keeps the JSON payload small.
|
||||
const cleaned: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(prefill)) {
|
||||
const trimmed = v.trim();
|
||||
if (trimmed) cleaned[k] = trimmed;
|
||||
}
|
||||
await customerAdminService.invite(
|
||||
email.trim(),
|
||||
Object.keys(cleaned).length > 0 ? cleaned : undefined,
|
||||
);
|
||||
toast.success(t('customers.invite.success', 'Invitation sent'));
|
||||
reset();
|
||||
onInvited();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.status === 409
|
||||
? t('customers.invite.conflict', 'A customer with this email already exists or has a pending invitation.')
|
||||
: e?.response?.data?.error || t('customers.invite.error', 'Could not send invitation.');
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 py-8 overflow-y-auto">
|
||||
<div className="w-full max-w-2xl rounded-xl shadow-lg my-auto" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
{t('customers.invite.title', 'Invite a customer')}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { reset(); onClose(); }}
|
||||
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
{t('customers.invite.description',
|
||||
'They\'ll receive an email with a link to set up their account. Once they\'ve accepted, you can assign them to events.')}
|
||||
</p>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customers.invite.email', 'Email')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
error={error || undefined}
|
||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPrefill((v) => !v)}
|
||||
className="text-sm font-medium hover:underline"
|
||||
style={{ color: 'var(--color-accent)' }}
|
||||
>
|
||||
{showPrefill
|
||||
? t('customers.invite.hidePrefill', '− Hide contact details')
|
||||
: t('customers.invite.showPrefill', '+ Add contact details (optional)')}
|
||||
</button>
|
||||
<p className="mt-1 text-xs text-muted-theme">
|
||||
{t('customers.invite.prefillHint',
|
||||
'Anything you fill in will be pre-populated on the customer\'s sign-up page — they can still edit it.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showPrefill && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.salutation', 'Salutation')}
|
||||
</label>
|
||||
<select
|
||||
value={prefill.salutation}
|
||||
onChange={(e) => updatePrefill('salutation', e.target.value)}
|
||||
className="w-full rounded-lg border px-3 h-10 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
|
||||
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
|
||||
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
|
||||
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
|
||||
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.displayName', 'Display name')}
|
||||
</label>
|
||||
<Input value={prefill.display_name} onChange={(e) => updatePrefill('display_name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.firstName', 'First name')}
|
||||
</label>
|
||||
<Input value={prefill.first_name} onChange={(e) => updatePrefill('first_name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.lastName', 'Last name')}
|
||||
</label>
|
||||
<Input value={prefill.last_name} onChange={(e) => updatePrefill('last_name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.phone', 'Phone')}
|
||||
</label>
|
||||
<Input value={prefill.phone} onChange={(e) => updatePrefill('phone', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.companyName', 'Company name')}
|
||||
</label>
|
||||
<Input value={prefill.company_name} onChange={(e) => updatePrefill('company_name', e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.vatId', 'VAT ID')}
|
||||
</label>
|
||||
<Input value={prefill.vat_id} onChange={(e) => updatePrefill('vat_id', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.addressLine1', 'Address line 1')}
|
||||
</label>
|
||||
<Input value={prefill.address_line1} onChange={(e) => updatePrefill('address_line1', e.target.value)} />
|
||||
</div>
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.addressLine2', 'Address line 2')}
|
||||
</label>
|
||||
<Input value={prefill.address_line2} onChange={(e) => updatePrefill('address_line2', e.target.value)} />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.postalCode', 'Postal code')}
|
||||
</label>
|
||||
<Input value={prefill.postal_code} onChange={(e) => updatePrefill('postal_code', e.target.value)} />
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.city', 'City')}
|
||||
</label>
|
||||
<Input value={prefill.city} onChange={(e) => updatePrefill('city', e.target.value)} />
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.countryCode', 'Country')}
|
||||
</label>
|
||||
<Input
|
||||
value={prefill.country_code}
|
||||
onChange={(e) => updatePrefill('country_code', e.target.value.toUpperCase().slice(0, 2))}
|
||||
placeholder="DE"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.state', 'State / region')}
|
||||
</label>
|
||||
<Input value={prefill.state} onChange={(e) => updatePrefill('state', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => { reset(); onClose(); }}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" isLoading={submitting} leftIcon={<UserPlus className="w-4 h-4" />}>
|
||||
{t('customers.invite.submit', 'Send invitation')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomerManagementPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('customers');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [confirm, setConfirm] = useState<{ kind: 'deactivate'; id: number; name: string } | { kind: 'cancelInvite'; id: number; email: string } | null>(null);
|
||||
|
||||
const { data: customers, isLoading: customersLoading, error: customersError } = useQuery({
|
||||
queryKey: ['admin-customers'],
|
||||
queryFn: () => customerAdminService.list(),
|
||||
});
|
||||
|
||||
const { data: invitations, isLoading: invitationsLoading, error: invitationsError } = useQuery({
|
||||
queryKey: ['admin-customer-invitations'],
|
||||
queryFn: () => customerAdminService.listInvitations(),
|
||||
});
|
||||
|
||||
const filteredCustomers = useMemo(() => {
|
||||
const list = customers || [];
|
||||
if (!searchTerm.trim()) return list;
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
return list.filter((c) =>
|
||||
c.email.toLowerCase().includes(term)
|
||||
|| (c.displayName || '').toLowerCase().includes(term)
|
||||
|| (c.lastName || '').toLowerCase().includes(term)
|
||||
|| (c.companyName || '').toLowerCase().includes(term)
|
||||
);
|
||||
}, [customers, searchTerm]);
|
||||
|
||||
const filteredInvitations = useMemo(() => {
|
||||
const list = invitations || [];
|
||||
if (!searchTerm.trim()) return list;
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
return list.filter((i) => i.email.toLowerCase().includes(term));
|
||||
}, [invitations, searchTerm]);
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
mutationFn: (id: number) => customerAdminService.deactivate(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
toast.success(t('customers.deactivate.success', 'Customer deactivated'));
|
||||
},
|
||||
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
|
||||
});
|
||||
|
||||
const cancelInviteMutation = useMutation({
|
||||
mutationFn: (id: number) => customerAdminService.cancelInvitation(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] });
|
||||
toast.success(t('customers.cancelInvitation.success', 'Invitation cancelled'));
|
||||
},
|
||||
onError: () => toast.error(t('customers.cancelInvitation.error', 'Could not cancel invitation')),
|
||||
});
|
||||
|
||||
const renderCustomerName = (c: CustomerAccountSummary) => {
|
||||
const display = c.displayName?.trim()
|
||||
|| [c.firstName, c.lastName].filter(Boolean).join(' ').trim()
|
||||
|| c.companyName?.trim();
|
||||
return display || <span className="text-muted-theme italic">{t('customers.unnamed', 'Unnamed')}</span>;
|
||||
};
|
||||
|
||||
const renderTabs = () => (
|
||||
<div className="flex gap-6 border-b mb-6" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('customers')}
|
||||
className={`pb-3 -mb-px border-b-2 text-sm font-medium ${
|
||||
activeTab === 'customers' ? 'border-accent text-accent' : 'border-transparent text-muted-theme hover:text-theme'
|
||||
}`}
|
||||
>
|
||||
{t('customers.tabs.customers', 'Customers')}
|
||||
{customers ? <span className="ml-2 text-xs">({customers.length})</span> : null}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('invitations')}
|
||||
className={`pb-3 -mb-px border-b-2 text-sm font-medium ${
|
||||
activeTab === 'invitations' ? 'border-accent text-accent' : 'border-transparent text-muted-theme hover:text-theme'
|
||||
}`}
|
||||
>
|
||||
{t('customers.tabs.invitations', 'Invitations')}
|
||||
{invitations ? <span className="ml-2 text-xs">({invitations.length})</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold text-theme">{t('customers.pageTitle', 'Customers')}</h1>
|
||||
{/* Beta badge — Calendar/Quotes/Bills tabs in the customer
|
||||
surface are placeholders, so flag the whole feature as
|
||||
still evolving. Keeps expectations honest. */}
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
|
||||
title="Beta — feature is functional but still evolving"
|
||||
>
|
||||
{t('navigation.betaTag', 'Beta')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mt-1">
|
||||
{t('customers.pageSubtitle', 'Recurring customer accounts that can log in at /customer/login.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" leftIcon={<UserPlus className="w-4 h-4" />} onClick={() => setInviteOpen(true)}>
|
||||
{t('customers.invite.button', 'Invite customer')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
{renderTabs()}
|
||||
|
||||
<div className="mb-4">
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t('customers.search.placeholder', 'Search by email, name, or company')}
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeTab === 'customers' ? (
|
||||
customersLoading ? (
|
||||
<div className="flex justify-center py-8"><Loading /></div>
|
||||
) : customersError ? (
|
||||
<div className="text-sm text-red-600 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t('customers.loadError', 'Could not load customers')}
|
||||
</div>
|
||||
) : filteredCustomers.length === 0 ? (
|
||||
<div className="text-center text-muted-theme py-12">
|
||||
{t('customers.empty', 'No customers yet. Click "Invite customer" to add one.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-theme">
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.name', 'Name')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.email', 'Email')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.company', 'Company')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.eventCount', 'Events')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.lastLogin', 'Last login')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.table.status', 'Status')}</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCustomers.map((c) => (
|
||||
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<td className="px-3 py-3">
|
||||
<Link to={`/admin/customers/${c.id}`} className="text-theme hover:underline">
|
||||
{renderCustomerName(c)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{c.email}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{c.companyName || '—'}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{c.eventCount ?? 0}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{formatDate(c.lastLogin)}</td>
|
||||
<td className="px-3 py-3">
|
||||
{c.isActive ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs" style={{ color: 'var(--color-accent)' }}>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{t('customers.status.active', 'Active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-red-600">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t('customers.status.inactive', 'Deactivated')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
{c.isActive && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
onClick={() => setConfirm({ kind: 'deactivate', id: c.id, name: c.email })}
|
||||
>
|
||||
{t('customers.deactivate.button', 'Deactivate')}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
invitationsLoading ? (
|
||||
<div className="flex justify-center py-8"><Loading /></div>
|
||||
) : invitationsError ? (
|
||||
<div className="text-sm text-red-600 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{t('customers.loadInvitationsError', 'Could not load invitations')}
|
||||
</div>
|
||||
) : filteredInvitations.length === 0 ? (
|
||||
<div className="text-center text-muted-theme py-12">
|
||||
{t('customers.invitations.empty', 'No pending invitations.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-theme">
|
||||
<th className="px-3 py-2 font-medium">{t('customers.invitations.email', 'Email')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.invitations.invitedBy', 'Invited by')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.invitations.expiresAt', 'Expires')}</th>
|
||||
<th className="px-3 py-2 font-medium">{t('customers.invitations.createdAt', 'Created')}</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredInvitations.map((inv: CustomerInvitationSummary) => (
|
||||
<tr key={inv.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<td className="px-3 py-3 text-theme">{inv.email}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{inv.invitedBy || '—'}</td>
|
||||
<td className="px-3 py-3 text-muted-theme">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
{formatDate(inv.expiresAt)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-muted-theme">{formatDate(inv.createdAt)}</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<X className="w-4 h-4" />}
|
||||
onClick={() => setConfirm({ kind: 'cancelInvite', id: inv.id, email: inv.email })}
|
||||
>
|
||||
{t('customers.invitations.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<InviteModal
|
||||
isOpen={inviteOpen}
|
||||
onClose={() => setInviteOpen(false)}
|
||||
onInvited={() => queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] })}
|
||||
/>
|
||||
|
||||
{confirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 mt-0.5 text-amber-500" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
{confirm.kind === 'deactivate'
|
||||
? t('customers.deactivate.title', 'Deactivate customer?')
|
||||
: t('customers.cancelInvitation.title', 'Cancel invitation?')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-muted-theme">
|
||||
{confirm.kind === 'deactivate'
|
||||
? t('customers.deactivate.body',
|
||||
'They will no longer be able to log in. You can re-invite them later.')
|
||||
: t('customers.cancelInvitation.body',
|
||||
'The invitation link will stop working immediately.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setConfirm(null)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (confirm.kind === 'deactivate') {
|
||||
deactivateMutation.mutate(confirm.id);
|
||||
} else {
|
||||
cancelInviteMutation.mutate(confirm.id);
|
||||
}
|
||||
setConfirm(null);
|
||||
}}
|
||||
isLoading={deactivateMutation.isPending || cancelInviteMutation.isPending}
|
||||
>
|
||||
{t('common.confirm', 'Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerManagementPage;
|
||||
@@ -59,6 +59,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading, MarkdownContent } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
|
||||
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
@@ -290,6 +291,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// off → no promo for this event regardless of global
|
||||
promo_mode: 'inherit' | 'custom' | 'off';
|
||||
promo_markdown: string;
|
||||
// Customer accounts assigned to this event (#354). Hydrated from
|
||||
// the GET /admin/events/:id response and sent back as a flat id
|
||||
// array on save.
|
||||
customer_accounts: Array<{ id: number; email: string; displayName: string | null }>;
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -330,6 +335,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// Per-event promotional override (#440)
|
||||
promo_mode: 'inherit',
|
||||
promo_markdown: '',
|
||||
// Customer accounts (#354) — hydrated from event response.
|
||||
customer_accounts: [],
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -588,6 +595,11 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// Per-event promotional override (#440)
|
||||
promo_mode: ((event as { promo_mode?: 'inherit' | 'custom' | 'off' }).promo_mode) || 'inherit',
|
||||
promo_markdown: (event as { promo_markdown?: string }).promo_markdown || '',
|
||||
// Customer accounts (#354). The backend returns
|
||||
// `customer_accounts: [{ id, email, display_name, ... }]`; map to
|
||||
// the picker's shape.
|
||||
customer_accounts: ((event as { customer_accounts?: Array<{ id: number; email: string; display_name?: string | null }> }).customer_accounts || [])
|
||||
.map((c) => ({ id: c.id, email: c.email, displayName: c.display_name ?? null })),
|
||||
});
|
||||
|
||||
setShowNewPassword(false);
|
||||
@@ -734,6 +746,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
// promo_markdown automatically when mode != 'custom'.
|
||||
promo_mode: editForm.promo_mode,
|
||||
promo_markdown: editForm.promo_mode === 'custom' ? editForm.promo_markdown : null,
|
||||
// Customer accounts (#354) — flat array of ids. Backend diffs
|
||||
// against existing assignments in one transaction.
|
||||
customer_account_ids: editForm.customer_accounts.map((c) => c.id),
|
||||
};
|
||||
|
||||
// Only include fields that have defined values
|
||||
@@ -1131,6 +1146,13 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer accounts (#354). Picker self-hides when the
|
||||
customerPortal feature flag is off. */}
|
||||
<CustomerAccountPicker
|
||||
value={editForm.customer_accounts}
|
||||
onChange={(next) => setEditForm((prev) => ({ ...prev, customer_accounts: next }))}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.expirationDate')}
|
||||
|
||||
@@ -12,5 +12,7 @@ export { CMSPage } from './CMSPage';
|
||||
export { BackupManagement } from './BackupManagement';
|
||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||
export { UserManagementPage } from './UserManagementPage';
|
||||
export { CustomerManagementPage } from './CustomerManagementPage';
|
||||
export { CustomerDetailPage } from './CustomerDetailPage';
|
||||
export { EventTypesPage } from './EventTypesPage';
|
||||
export { WebhookDeliveriesPage } from './WebhookDeliveriesPage';
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Customer accept-invite page (#354).
|
||||
*
|
||||
* Mounted at /customer/invite/:token. Public route — anyone with the link
|
||||
* can complete the invitation. The token IS the auth: 256 bits of entropy,
|
||||
* single-use, 7-day TTL, server-side validated.
|
||||
*
|
||||
* Now collects the full profile (#354 follow-up):
|
||||
* - admin can pre-fill any subset on /admin/customers invite, those
|
||||
* values appear pre-populated and editable here
|
||||
* - customer can correct or fill in anything else (phone, billing
|
||||
* address, company)
|
||||
* - password is the only required field besides the display name
|
||||
*
|
||||
* The profile fields are optional — a customer who just wants to log in
|
||||
* fast can leave them blank and edit later from /customer/profile.
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Lock, MapPin, Phone, User as UserIcon, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import {
|
||||
customerService,
|
||||
type CustomerInvitationInfo,
|
||||
type CustomerProfilePrefill,
|
||||
} from '../../services/customer.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
interface FormState {
|
||||
display_name: string;
|
||||
password: string;
|
||||
confirm: string;
|
||||
salutation: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone: string;
|
||||
company_name: string;
|
||||
vat_id: string;
|
||||
address_line1: string;
|
||||
address_line2: string;
|
||||
postal_code: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
const SALUTATION_OPTIONS = [
|
||||
{ value: '', labelKey: 'customer.profile.salutation.none', fallback: '— Not specified —' },
|
||||
{ value: 'Herr', labelKey: 'customer.profile.salutation.herr', fallback: 'Herr' },
|
||||
{ value: 'Frau', labelKey: 'customer.profile.salutation.frau', fallback: 'Frau' },
|
||||
{ value: 'Mx', labelKey: 'customer.profile.salutation.mx', fallback: 'Mx' },
|
||||
{ value: 'Dr', labelKey: 'customer.profile.salutation.dr', fallback: 'Dr.' },
|
||||
];
|
||||
|
||||
const EMPTY: FormState = {
|
||||
display_name: '', password: '', confirm: '',
|
||||
salutation: '', first_name: '', last_name: '',
|
||||
phone: '', company_name: '', vat_id: '',
|
||||
address_line1: '', address_line2: '', postal_code: '', city: '', state: '', country_code: '',
|
||||
};
|
||||
|
||||
export const CustomerAcceptInvitePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token = '' } = useParams<{ token: string }>();
|
||||
|
||||
const [invitation, setInvitation] = useState<CustomerInvitationInfo | null>(null);
|
||||
const [lookupError, setLookupError] = useState<string | null>(null);
|
||||
const [isLookingUp, setIsLookingUp] = useState(true);
|
||||
|
||||
const [form, setForm] = useState<FormState>(EMPTY);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// Pre-flight invitation lookup. The response carries any prefill data
|
||||
// the admin attached on /admin/customers invite — populate the form
|
||||
// with that so the customer doesn't retype what their photographer
|
||||
// already knows.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLookingUp(true);
|
||||
customerService.getInvitation(token)
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
setInvitation(info);
|
||||
if (info.prefill) {
|
||||
setForm((prev) => ({ ...prev, ...mergePrefillIntoForm(prev, info.prefill!) }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setLookupError(t(
|
||||
'customer.acceptInvite.invalidToken',
|
||||
'This invitation link is invalid or has expired. Please contact your photographer for a new invitation.'
|
||||
));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLookingUp(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [token, t]);
|
||||
|
||||
/**
|
||||
* The display_name shown in the form is admin-prefilled if available,
|
||||
* otherwise constructed from first/last name so the customer sees
|
||||
* something sensible without us silently changing what they type.
|
||||
*/
|
||||
const initialDisplayName = useMemo(() => {
|
||||
if (form.display_name) return form.display_name;
|
||||
const fromParts = [form.first_name, form.last_name].filter(Boolean).join(' ').trim();
|
||||
return fromParts || '';
|
||||
}, [form.display_name, form.first_name, form.last_name]);
|
||||
|
||||
const update = (key: keyof FormState, value: string) => {
|
||||
setForm((p) => ({ ...p, [key]: value }));
|
||||
};
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!initialDisplayName.trim()) {
|
||||
next.display_name = t('customer.acceptInvite.nameRequired', 'Please enter your name');
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
next.password = t('customer.acceptInvite.passwordTooShort', 'Password must be at least 8 characters');
|
||||
}
|
||||
if (form.password !== form.confirm) {
|
||||
next.confirm = t('customer.acceptInvite.passwordsMismatch', 'Passwords do not match');
|
||||
}
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const profile: CustomerProfilePrefill = {
|
||||
salutation: form.salutation || undefined,
|
||||
first_name: form.first_name || undefined,
|
||||
last_name: form.last_name || undefined,
|
||||
display_name: form.display_name || undefined,
|
||||
phone: form.phone || undefined,
|
||||
company_name: form.company_name || undefined,
|
||||
vat_id: form.vat_id || undefined,
|
||||
address_line1: form.address_line1 || undefined,
|
||||
address_line2: form.address_line2 || undefined,
|
||||
postal_code: form.postal_code || undefined,
|
||||
city: form.city || undefined,
|
||||
state: form.state || undefined,
|
||||
country_code: form.country_code || undefined,
|
||||
};
|
||||
await customerService.acceptInvitation(token, initialDisplayName.trim(), form.password, profile);
|
||||
toast.success(t('customer.acceptInvite.successToast', 'Account created — please log in.'));
|
||||
navigate('/customer/login?accepted=1', { replace: true });
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 409) {
|
||||
setErrors({ form: t('customer.acceptInvite.alreadyExists', 'An account with this email already exists. Please log in instead.') });
|
||||
} else if (error.response?.data?.details?.length) {
|
||||
setErrors({ password: error.response.data.details.join(' ') });
|
||||
} else if (error.response?.status === 400) {
|
||||
setErrors({ form: error.response?.data?.error || t('customer.acceptInvite.invalidSubmission', 'Could not create your account.') });
|
||||
} else {
|
||||
toast.error(t('customer.acceptInvite.generalError', 'Could not create your account. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="customer-surface min-h-screen flex items-center justify-center px-4 py-8"
|
||||
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
|
||||
>
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={companyName}
|
||||
className="h-16 w-auto object-contain mx-auto mb-4"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-theme">
|
||||
{t('customer.acceptInvite.title', 'Set up your account')}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-theme">
|
||||
{t('customer.acceptInvite.subtitle', 'Confirm or fill in your details. You can edit anything from the profile page later.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
{isLookingUp ? (
|
||||
<div className="flex justify-center py-8"><Loading size="lg" /></div>
|
||||
) : lookupError || !invitation ? (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0 text-red-600" />
|
||||
<p className="text-theme">{lookupError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg" style={{ backgroundColor: 'var(--color-elevated, #f5f5f5)' }}>
|
||||
<CheckCircle className="w-5 h-5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
|
||||
<div className="text-sm text-theme">
|
||||
{t('customer.acceptInvite.emailWillBe', 'Your account email will be ')}
|
||||
<span className="font-medium">{invitation.email}</span>
|
||||
{invitation.invitedBy ? (
|
||||
<>
|
||||
{t('customer.acceptInvite.invitedBy', ', invited by ')}
|
||||
<span className="font-medium">{invitation.invitedBy}</span>
|
||||
</>
|
||||
) : null}
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.form && (
|
||||
<div role="alert" className="flex items-start gap-2 p-3 rounded-lg border" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-600" />
|
||||
<span className="text-sm text-theme">{errors.form}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Personal — required: display name + password */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-theme flex items-center gap-2">
|
||||
<UserIcon className="w-4 h-4" />
|
||||
{t('customer.acceptInvite.section.personal', 'Personal')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.salutation', 'Salutation')}
|
||||
</label>
|
||||
<select
|
||||
value={form.salutation}
|
||||
onChange={(e) => update('salutation', e.target.value)}
|
||||
className="w-full rounded-lg border px-3 h-10 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{SALUTATION_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{t(o.labelKey, o.fallback)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.acceptInvite.displayName', 'Display name')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => update('display_name', e.target.value)}
|
||||
error={errors.display_name}
|
||||
autoComplete="name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-first-name">
|
||||
{t('customer.profile.field.firstName', 'First name')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-first-name"
|
||||
name="given-name"
|
||||
autoComplete="given-name"
|
||||
value={form.first_name}
|
||||
onChange={(e) => update('first_name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-last-name">
|
||||
{t('customer.profile.field.lastName', 'Last name')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-last-name"
|
||||
name="family-name"
|
||||
autoComplete="family-name"
|
||||
value={form.last_name}
|
||||
onChange={(e) => update('last_name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Contact — all optional, the photographer will probably
|
||||
appreciate having the phone for last-minute schedule
|
||||
changes but no customer should be blocked on it. */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-theme flex items-center gap-2">
|
||||
<Phone className="w-4 h-4" />
|
||||
{t('customer.acceptInvite.section.contact', 'Contact & business (optional)')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-phone">
|
||||
{t('customer.profile.field.phone', 'Phone')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-phone"
|
||||
name="tel"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={form.phone}
|
||||
onChange={(e) => update('phone', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-company">
|
||||
{t('customer.profile.field.companyName', 'Company name')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-company"
|
||||
name="organization"
|
||||
autoComplete="organization"
|
||||
value={form.company_name}
|
||||
onChange={(e) => update('company_name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-vat">
|
||||
{t('customer.profile.field.vatId', 'VAT ID')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-vat"
|
||||
name="vat-id"
|
||||
value={form.vat_id}
|
||||
onChange={(e) => update('vat_id', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Address */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-theme flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4" />
|
||||
{t('customer.acceptInvite.section.address', 'Billing address (optional)')}
|
||||
</h2>
|
||||
{/* Same `name`+`autoComplete` pairing the profile page
|
||||
uses — see CustomerProfilePage for the rationale. */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-address-line1">
|
||||
{t('customer.profile.field.addressLine1', 'Address line 1')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-address-line1"
|
||||
name="address-line1"
|
||||
autoComplete="billing address-line1"
|
||||
value={form.address_line1}
|
||||
onChange={(e) => update('address_line1', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-address-line2">
|
||||
{t('customer.profile.field.addressLine2', 'Address line 2')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-address-line2"
|
||||
name="address-line2"
|
||||
autoComplete="billing address-line2"
|
||||
value={form.address_line2}
|
||||
onChange={(e) => update('address_line2', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-postal-code">
|
||||
{t('customer.profile.field.postalCode', 'Postal code')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-postal-code"
|
||||
name="postal-code"
|
||||
autoComplete="billing postal-code"
|
||||
inputMode="numeric"
|
||||
value={form.postal_code}
|
||||
onChange={(e) => update('postal_code', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-city">
|
||||
{t('customer.profile.field.city', 'City')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-city"
|
||||
name="address-level2"
|
||||
autoComplete="billing address-level2"
|
||||
value={form.city}
|
||||
onChange={(e) => update('city', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-country">
|
||||
{t('customer.profile.field.countryCode', 'Country')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-country"
|
||||
name="country"
|
||||
autoComplete="billing country"
|
||||
placeholder="DE"
|
||||
maxLength={2}
|
||||
value={form.country_code}
|
||||
onChange={(e) => update('country_code', e.target.value.toUpperCase().slice(0, 2))}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-state">
|
||||
{t('customer.profile.field.state', 'State / region')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-state"
|
||||
name="address-level1"
|
||||
autoComplete="billing address-level1"
|
||||
value={form.state}
|
||||
onChange={(e) => update('state', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Password — required */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-theme flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" />
|
||||
{t('customer.acceptInvite.section.password', 'Choose a password')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.acceptInvite.password', 'Password')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => update('password', e.target.value)}
|
||||
error={errors.password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.acceptInvite.confirm', 'Confirm password')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.confirm}
|
||||
onChange={(e) => update('confirm', e.target.value)}
|
||||
error={errors.confirm}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-theme">
|
||||
{t('customer.acceptInvite.passwordHint', 'At least 8 characters, with one uppercase letter and one number.')}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="w-full">
|
||||
{t('customer.acceptInvite.submit', 'Create account')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate the snake_case prefill payload (matches the backend wire shape)
|
||||
* into the camelCase-ish form fields the local state uses. Kept inline
|
||||
* because it's only ever called once on mount.
|
||||
*/
|
||||
function mergePrefillIntoForm(current: FormState, prefill: CustomerProfilePrefill): Partial<FormState> {
|
||||
const out: Partial<FormState> = {};
|
||||
if (prefill.salutation && !current.salutation) out.salutation = prefill.salutation;
|
||||
if (prefill.first_name && !current.first_name) out.first_name = prefill.first_name;
|
||||
if (prefill.last_name && !current.last_name) out.last_name = prefill.last_name;
|
||||
if (prefill.display_name && !current.display_name) out.display_name = prefill.display_name;
|
||||
if (prefill.phone && !current.phone) out.phone = prefill.phone;
|
||||
if (prefill.company_name && !current.company_name) out.company_name = prefill.company_name;
|
||||
if (prefill.vat_id && !current.vat_id) out.vat_id = prefill.vat_id;
|
||||
if (prefill.address_line1 && !current.address_line1) out.address_line1 = prefill.address_line1;
|
||||
if (prefill.address_line2 && !current.address_line2) out.address_line2 = prefill.address_line2;
|
||||
if (prefill.postal_code && !current.postal_code) out.postal_code = prefill.postal_code;
|
||||
if (prefill.city && !current.city) out.city = prefill.city;
|
||||
if (prefill.state && !current.state) out.state = prefill.state;
|
||||
if (prefill.country_code && !current.country_code) out.country_code = prefill.country_code;
|
||||
return out;
|
||||
}
|
||||
|
||||
export default CustomerAcceptInvitePage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Receipt } from 'lucide-react';
|
||||
import { CustomerComingSoonPage } from './CustomerComingSoonPage';
|
||||
|
||||
export const CustomerBillsPage: React.FC = () => (
|
||||
<CustomerComingSoonPage
|
||||
titleKey="customer.bills.title"
|
||||
titleFallback="Bills"
|
||||
bodyKey="customer.bills.body"
|
||||
bodyFallback="Invoices and payment history for your sessions will be available here. We'll send you an email when this is live."
|
||||
icon={Receipt}
|
||||
/>
|
||||
);
|
||||
|
||||
export default CustomerBillsPage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { CustomerComingSoonPage } from './CustomerComingSoonPage';
|
||||
|
||||
export const CustomerCalendarPage: React.FC = () => (
|
||||
<CustomerComingSoonPage
|
||||
titleKey="customer.calendar.title"
|
||||
titleFallback="Calendar"
|
||||
bodyKey="customer.calendar.body"
|
||||
bodyFallback="Upcoming sessions, gallery delivery dates, and other shoot-related events will land here. We'll let you know when it's ready."
|
||||
icon={Calendar}
|
||||
/>
|
||||
);
|
||||
|
||||
export default CustomerCalendarPage;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Generic placeholder for customer-surface features that aren't built yet
|
||||
* but are surfaced in the sidebar so the maintainer can demo the layout
|
||||
* without the feature being live (Calendar, Quotes, Bills — #354 follow-ups).
|
||||
*
|
||||
* Single component re-used for all three; the calling page passes the title
|
||||
* + lucide icon so each route stays distinguishable in the address bar and
|
||||
* heading.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CustomerComingSoonPageProps {
|
||||
titleKey: string;
|
||||
titleFallback: string;
|
||||
bodyKey: string;
|
||||
bodyFallback: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export const CustomerComingSoonPage: React.FC<CustomerComingSoonPageProps> = ({
|
||||
titleKey, titleFallback, bodyKey, bodyFallback, icon: Icon,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="container py-8 sm:py-16">
|
||||
<div
|
||||
className="max-w-xl mx-auto rounded-xl border p-8 sm:p-12 text-center"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mx-auto mb-4 w-14 h-14 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)' }}
|
||||
>
|
||||
<Icon className="w-7 h-7" style={{ color: 'var(--color-accent)' }} />
|
||||
</div>
|
||||
<span
|
||||
className="inline-block text-[10px] uppercase tracking-wider px-2 py-0.5 rounded font-semibold mb-3"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
{t('customer.comingSoon.tag', 'Coming soon')}
|
||||
</span>
|
||||
<h1 className="text-2xl font-bold text-theme mb-2">
|
||||
{t(titleKey, titleFallback)}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-theme leading-relaxed">
|
||||
{t(bodyKey, bodyFallback)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerComingSoonPage;
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Customer dashboard (#354) — list of every gallery the admin has granted
|
||||
* this customer access to. Mounted at /customer/dashboard.
|
||||
*
|
||||
* Now a layout-wrapped page (Outlet child of CustomerLayout) — no inner
|
||||
* <CustomerLayout> wrapper.
|
||||
*
|
||||
* Design changes (#354 follow-up):
|
||||
* - inline list rows instead of card grid (the maintainer asked for a
|
||||
* denser, more spreadsheet-like view — works better when a customer
|
||||
* has many recurring weddings)
|
||||
* - sort dropdown: Name / Newest first / Oldest first
|
||||
* - per-row Open + Download buttons (download bypasses the gallery and
|
||||
* bundles a zip in one click)
|
||||
*
|
||||
* Card click → exchange the customer JWT for a per-event gallery JWT via
|
||||
* /api/customer/events/:slug/access-token, the backend writes the
|
||||
* gallery_token_<slug> cookie alongside the JSON response, then we navigate
|
||||
* to /gallery/:slug. The gallery code path needs no changes — it sees a
|
||||
* regular gallery token exactly as if the per-event password had been
|
||||
* entered.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Calendar, Clock, Download, ExternalLink, ImageIcon, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Button, Loading } from '../../components/common';
|
||||
import { customerService, type CustomerEvent } from '../../services/customer.service';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { storeGalleryToken, setActiveGallerySlug } from '../../utils/galleryAuthStorage';
|
||||
|
||||
type SortKey = 'newest' | 'oldest' | 'name';
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: SortKey; labelKey: string; fallback: string }> = [
|
||||
{ value: 'newest', labelKey: 'customer.dashboard.sortNewest', fallback: 'Newest first' },
|
||||
{ value: 'oldest', labelKey: 'customer.dashboard.sortOldest', fallback: 'Oldest first' },
|
||||
{ value: 'name', labelKey: 'customer.dashboard.sortName', fallback: 'By name' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Default to newest-first because that's almost always what a returning
|
||||
* customer wants ("which gallery did they upload yesterday?"). The other
|
||||
* orderings are mostly useful for archival browsing.
|
||||
*/
|
||||
const DEFAULT_SORT: SortKey = 'newest';
|
||||
|
||||
export const CustomerDashboardPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: events, isLoading, error } = useQuery({
|
||||
queryKey: ['customer-events'],
|
||||
queryFn: () => customerService.listEvents(),
|
||||
});
|
||||
|
||||
const [openingSlug, setOpeningSlug] = useState<string | null>(null);
|
||||
const [downloadingSlug, setDownloadingSlug] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortKey>(DEFAULT_SORT);
|
||||
|
||||
const sortedEvents = useMemo(() => {
|
||||
const list: CustomerEvent[] = (events || []).slice();
|
||||
// Use eventDate (the wedding/shoot date) as the primary key for date
|
||||
// sorts; fall back to assignedAt when the event has no date set so
|
||||
// entries don't all collapse to the bottom. Name sort is a basic
|
||||
// case-insensitive locale compare.
|
||||
const dateOf = (e: CustomerEvent) => (e.eventDate || e.assignedAt || '');
|
||||
if (sort === 'newest') {
|
||||
list.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
|
||||
} else if (sort === 'oldest') {
|
||||
list.sort((a, b) => dateOf(a).localeCompare(dateOf(b)));
|
||||
} else {
|
||||
list.sort((a, b) => a.eventName.localeCompare(b.eventName, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
return list;
|
||||
}, [events, sort]);
|
||||
|
||||
const openEvent = async (slug: string) => {
|
||||
if (openingSlug) return;
|
||||
setOpeningSlug(slug);
|
||||
try {
|
||||
const { token } = await customerService.getEventAccessToken(slug);
|
||||
storeGalleryToken(slug, token);
|
||||
setActiveGallerySlug(slug);
|
||||
navigate(`/gallery/${encodeURIComponent(slug)}`);
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status;
|
||||
if (status === 410) {
|
||||
toast.error(t('customer.dashboard.eventExpired', 'This gallery has expired.'));
|
||||
} else if (status === 403) {
|
||||
toast.error(t('customer.dashboard.eventForbidden', 'You no longer have access to this gallery.'));
|
||||
} else {
|
||||
toast.error(t('customer.dashboard.openError', 'Could not open this gallery. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setOpeningSlug(null);
|
||||
}
|
||||
};
|
||||
|
||||
const quickDownload = async (slug: string, eventName: string) => {
|
||||
if (downloadingSlug) return;
|
||||
setDownloadingSlug(slug);
|
||||
try {
|
||||
const { token } = await customerService.getEventAccessToken(slug);
|
||||
storeGalleryToken(slug, token);
|
||||
setActiveGallerySlug(slug);
|
||||
await galleryService.downloadAllPhotos(slug, false);
|
||||
toast.success(t('customer.dashboard.downloadStarted', 'Download started for {{name}}', { name: eventName }));
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status;
|
||||
if (status === 410) {
|
||||
toast.error(t('customer.dashboard.eventExpired', 'This gallery has expired.'));
|
||||
} else if (status === 403) {
|
||||
toast.error(t('customer.dashboard.eventForbidden', 'You no longer have access to this gallery.'));
|
||||
} else {
|
||||
toast.error(t('customer.dashboard.downloadError', 'Could not start the download. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setDownloadingSlug(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return null;
|
||||
try { return format(parseISO(iso), 'PP'); } catch { return null; }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-6 sm:py-8">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-theme">
|
||||
{t('customer.dashboard.title', 'Your galleries')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-theme">
|
||||
{t('customer.dashboard.subtitle', 'Click a gallery to open it. The Download button bundles every photo as a zip.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sort dropdown — only render when there's something to sort. */}
|
||||
{(events?.length || 0) > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="customer-events-sort" className="text-sm text-muted-theme whitespace-nowrap">
|
||||
{t('customer.dashboard.sortLabel', 'Sort by')}
|
||||
</label>
|
||||
<select
|
||||
id="customer-events-sort"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortKey)}
|
||||
className="rounded-lg border px-3 h-9 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{t(opt.labelKey, opt.fallback)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-16"><Loading size="lg" /></div>
|
||||
) : error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-6 flex items-start gap-3"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0 text-red-500" />
|
||||
<p className="text-theme">
|
||||
{t('customer.dashboard.loadError', 'Could not load your galleries. Please try again.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (sortedEvents.length === 0) ? (
|
||||
<div
|
||||
className="rounded-xl border p-6"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
}}
|
||||
>
|
||||
<div className="text-center py-12">
|
||||
<ImageIcon className="w-12 h-12 mx-auto mb-3 text-muted-theme" aria-hidden="true" />
|
||||
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||
{t('customer.dashboard.emptyTitle', 'No galleries yet')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-theme">
|
||||
{t(
|
||||
'customer.dashboard.emptyBody',
|
||||
'Once your photographer assigns you to a gallery, it will appear here.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Inline list — one row per gallery, no card grid. Hover affordance
|
||||
// via the entire row acting as a button (Open) plus a separate
|
||||
// Download icon button so click bubbling doesn't cross-trigger.
|
||||
<div
|
||||
className="rounded-xl border overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
}}
|
||||
>
|
||||
<ul className="divide-y" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
{sortedEvents.map((ev) => {
|
||||
const date = formatDate(ev.eventDate);
|
||||
const expires = formatDate(ev.expiresAt);
|
||||
const isExpired = ev.expiresAt ? new Date(ev.expiresAt) < new Date() : false;
|
||||
const isOpening = openingSlug === ev.slug;
|
||||
const isDownloading = downloadingSlug === ev.slug;
|
||||
const rowDisabled = isExpired || openingSlug !== null || downloadingSlug !== null;
|
||||
return (
|
||||
<li
|
||||
key={ev.id}
|
||||
className="px-4 py-3 sm:px-5 sm:py-4 flex items-center gap-3 sm:gap-4"
|
||||
style={{
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
opacity: isExpired ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm sm:text-base font-semibold text-theme truncate">
|
||||
{ev.eventName}
|
||||
</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-theme">
|
||||
{date && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Calendar className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{date}
|
||||
</span>
|
||||
)}
|
||||
{expires && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{isExpired
|
||||
? t('customer.dashboard.expiredOn', 'Expired {{date}}', { date: expires })
|
||||
: t('customer.dashboard.expiresOn', 'Expires {{date}}', { date: expires })}
|
||||
</span>
|
||||
)}
|
||||
{isOpening && (
|
||||
<span className="text-xs" style={{ color: 'var(--color-accent)' }}>
|
||||
{t('customer.dashboard.opening', 'Opening…')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!isExpired && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => quickDownload(ev.slug, ev.eventName)}
|
||||
disabled={rowDisabled}
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
aria-label={t('customer.dashboard.quickDownloadAria', 'Download all photos for {{name}}', { name: ev.eventName })}
|
||||
>
|
||||
<span className="hidden sm:inline">
|
||||
{isDownloading
|
||||
? t('customer.dashboard.preparingDownload', 'Preparing…')
|
||||
: t('customer.dashboard.download', 'Download')}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => openEvent(ev.slug)}
|
||||
disabled={rowDisabled}
|
||||
leftIcon={<ExternalLink className="w-4 h-4" />}
|
||||
aria-label={t('customer.dashboard.openAria', 'Open gallery {{name}}', { name: ev.eventName })}
|
||||
>
|
||||
<span className="hidden sm:inline">
|
||||
{t('customer.dashboard.open', 'Open')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerDashboardPage;
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Customer surface shell (#354).
|
||||
*
|
||||
* Visually patterned after the admin layout (sidebar + top header + scrollable
|
||||
* main) — the maintainer asked for parity with /admin/* so admins dogfooding
|
||||
* the customer flow get a familiar structure. Differences from AdminLayout:
|
||||
* - no AdminSidebar / RBAC permission gating; customers don't have roles
|
||||
* - branding header (logo + company name) sits inside the sidebar so the
|
||||
* customer surface looks like *their* photographer's site, not picpeak
|
||||
* chrome
|
||||
* - calendar / quotes / bills nav items are stubbed (coming-soon pages);
|
||||
* they're shown to the user behind a small "Coming soon" tag because
|
||||
* they're built but intentionally inert until the matching backends ship
|
||||
*
|
||||
* Renders as a layout route (Outlet pattern) so individual pages don't need
|
||||
* to wrap their content in `<CustomerLayout>` — same approach AdminLayout uses.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Link, NavLink, Outlet, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Calendar,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
LogOut,
|
||||
Menu,
|
||||
Receipt,
|
||||
User as UserIcon,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
labelKey: string;
|
||||
fallback: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* Optional gate — entry only renders when the matching feature is
|
||||
* effective for this customer (i.e. global toggle ON and per-customer
|
||||
* flag ON, AND-combined server-side in /api/customer/auth/session).
|
||||
* Galleries + Profile are always visible; Calendar/Quotes/Bills are
|
||||
* gated.
|
||||
*/
|
||||
feature?: 'calendar' | 'quotes' | 'bills';
|
||||
}
|
||||
|
||||
const NAV: NavItem[] = [
|
||||
{ to: '/customer/dashboard', labelKey: 'customer.nav.galleries', fallback: 'Galleries', icon: ImageIcon },
|
||||
{ to: '/customer/calendar', labelKey: 'customer.nav.calendar', fallback: 'Calendar', icon: Calendar, feature: 'calendar' },
|
||||
{ to: '/customer/quotes', labelKey: 'customer.nav.quotes', fallback: 'Quotes', icon: FileText, feature: 'quotes' },
|
||||
{ to: '/customer/bills', labelKey: 'customer.nav.bills', fallback: 'Bills', icon: Receipt, feature: 'bills' },
|
||||
{ to: '/customer/profile', labelKey: 'customer.nav.profile', fallback: 'Profile', icon: UserIcon },
|
||||
];
|
||||
|
||||
export const CustomerLayout: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { customer, features, branding, isAuthenticated, isLoading, logout } = useCustomerAuth();
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// Filter out feature-gated entries the customer can't see. Galleries +
|
||||
// Profile have no feature property, so they're always present.
|
||||
const visibleNav = NAV.filter((item) => !item.feature || features[item.feature] === true);
|
||||
|
||||
// Branding visibility — admin can hide either piece independently. If
|
||||
// both are hidden the brand link still exists (you can click it to
|
||||
// reach /customer/dashboard) but renders empty space at zero height.
|
||||
const showLogo = branding.showLogo;
|
||||
const showCompanyName = branding.showCompanyName;
|
||||
|
||||
// Loading screen mirrors AdminLayout's so admin-as-customer dogfooding
|
||||
// sees a familiar transition. Background uses the theme variable so a
|
||||
// dark Branding palette doesn't flash white on first paint.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
|
||||
>
|
||||
<div className="w-12 h-12 border-4 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--color-accent)', borderTopColor: 'transparent' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/customer/login" replace />;
|
||||
}
|
||||
|
||||
const greetingName = customer?.displayName
|
||||
|| customer?.firstName
|
||||
|| (customer?.email ? customer.email.split('@')[0] : '');
|
||||
|
||||
return (
|
||||
<div
|
||||
// The `customer-surface` marker is read by index.css to retheme
|
||||
// <Input> components via CSS variables — admin uses tailwind's
|
||||
// `dark:` modifier (toggled on <html>), but the customer surface
|
||||
// uses theme tokens so we scope the override here.
|
||||
className="customer-surface h-screen flex overflow-hidden"
|
||||
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
|
||||
>
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 border-r transform transition-transform duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface, #ffffff)',
|
||||
borderColor: 'var(--color-surface-border, #e5e5e5)',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col h-screen lg:h-full">
|
||||
{/* Brand */}
|
||||
<div
|
||||
className="flex items-center justify-between h-16 px-4 border-b flex-shrink-0"
|
||||
style={{ borderColor: 'var(--color-surface-border, #e5e5e5)' }}
|
||||
>
|
||||
<Link
|
||||
to="/customer/dashboard"
|
||||
className="flex items-center gap-2 min-w-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 rounded"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
{showLogo && (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={companyName}
|
||||
className="h-8 w-auto object-contain flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
{showCompanyName && (
|
||||
<span className="text-sm font-semibold text-theme truncate">
|
||||
{companyName}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="lg:hidden text-muted-theme hover:text-theme"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto min-h-0">
|
||||
{visibleNav.map((item) => {
|
||||
const Icon = item.icon;
|
||||
// Active when the path matches or starts with the entry —
|
||||
// dashboard stays active only on exact match so the other
|
||||
// nav entries don't double-highlight on /customer/dashboard.
|
||||
const isActive = item.to === '/customer/dashboard'
|
||||
? location.pathname === item.to
|
||||
: location.pathname === item.to || location.pathname.startsWith(`${item.to}/`);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={isActive ? {
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 12%, transparent)',
|
||||
color: 'var(--color-accent)',
|
||||
} : undefined}
|
||||
>
|
||||
<Icon className={`w-5 h-5 flex-shrink-0 ${isActive ? '' : 'text-muted-theme'}`} />
|
||||
<span className={`flex-1 truncate ${isActive ? '' : 'text-theme'}`}>
|
||||
{t(item.labelKey, item.fallback)}
|
||||
</span>
|
||||
{/* Coming-soon pill for the gated entries. The pages
|
||||
themselves are still placeholders even when the
|
||||
admin has enabled access — the badge keeps that
|
||||
promise honest. */}
|
||||
{item.feature && (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 14%, transparent)',
|
||||
color: 'var(--color-accent)',
|
||||
}}
|
||||
>
|
||||
{t('customer.nav.soon', 'Soon')}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer (logout + greeting on a single line, mirrors admin) */}
|
||||
<div
|
||||
className="border-t px-4 py-3 flex items-center justify-between gap-2"
|
||||
style={{ borderColor: 'var(--color-surface-border, #e5e5e5)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-theme truncate">{greetingName}</div>
|
||||
<div className="text-xs text-muted-theme truncate">{customer?.email}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void logout(); }}
|
||||
className="p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-muted-theme hover:text-theme focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
aria-label={t('common.logout', 'Logout')}
|
||||
title={t('common.logout', 'Logout')}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main column */}
|
||||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||||
<header
|
||||
className="lg:hidden h-14 px-4 flex items-center justify-between border-b flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface, #ffffff)',
|
||||
borderColor: 'var(--color-surface-border, #e5e5e5)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-theme"
|
||||
aria-label={t('common.menu', 'Menu')}
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-theme truncate">{companyName}</span>
|
||||
<span className="w-9" aria-hidden="true" />
|
||||
</header>
|
||||
|
||||
<main id="customer-main" className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer
|
||||
className="py-4 px-4 text-center text-xs"
|
||||
style={{ color: 'var(--color-muted-text, #737373)' }}
|
||||
>
|
||||
<p>
|
||||
{settingsData?.branding_footer_text
|
||||
|| `© ${new Date().getFullYear()} ${companyName}. All rights reserved.`}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerLayout;
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Customer login page (#354).
|
||||
*
|
||||
* Mounted at /customer/login. Strictly separate from /admin/login —
|
||||
* different auth context, different cookie, different backend route.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Navigate, useSearchParams } from 'react-router-dom';
|
||||
import { Lock, Mail, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
||||
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
|
||||
import { customerService } from '../../services/customer.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
export const CustomerLoginPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { isAuthenticated, setSession } = useCustomerAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [formData, setFormData] = useState({ email: '', password: '' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// After /accept-invite the user is redirected here with ?accepted=1
|
||||
// so we can show a friendly success toast on first paint.
|
||||
React.useEffect(() => {
|
||||
if (searchParams.get('accepted') === '1') {
|
||||
toast.success(t('customer.login.acceptedToast', 'Account ready — please log in.'));
|
||||
}
|
||||
}, [searchParams, t]);
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/customer/dashboard" replace />;
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!formData.email) {
|
||||
next.email = t('customer.login.emailRequired', 'Email is required');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
next.email = t('customer.login.invalidEmail', 'Please enter a valid email');
|
||||
}
|
||||
if (!formData.password) {
|
||||
next.password = t('customer.login.passwordRequired', 'Password is required');
|
||||
}
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
toast.dismiss();
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setErrors({});
|
||||
try {
|
||||
const response = await customerService.login(
|
||||
formData.email,
|
||||
formData.password,
|
||||
recaptchaToken
|
||||
);
|
||||
// Apply the full session payload (customer + features + branding)
|
||||
// so the dashboard's first paint shows the correct sidebar. Using
|
||||
// setCustomer alone left features at DEFAULT_FEATURES (all false)
|
||||
// until the next CustomerAuthProvider remount, which is why the
|
||||
// Soon menus only appeared after navigating to a gallery and back.
|
||||
setSession(response);
|
||||
toast.success(t('customer.login.loginSuccess', 'Welcome back!'));
|
||||
// Navigate via Navigate component on next render — setCustomer
|
||||
// flips isAuthenticated true so the redirect at the top fires.
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 429 || error.response?.status === 423) {
|
||||
toast.error(t('customer.login.tooManyAttempts', 'Too many attempts — please try again later.'));
|
||||
} else if (error.response?.status === 401) {
|
||||
setErrors({ form: t('customer.login.invalidCredentials', 'Invalid email or password') });
|
||||
} else if (error.code === 'ERR_NETWORK') {
|
||||
toast.error(t('customer.login.networkError', 'Could not reach the server. Please try again.'));
|
||||
} else {
|
||||
toast.error(t('customer.login.generalError', 'Login failed. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: 'email' | 'password') =>
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
if (errors[field]) setErrors((prev) => ({ ...prev, [field]: '' }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
// Visual structure mirrors AdminLoginPage so admin and customer
|
||||
// landings feel like the same product: tinted logo frame above
|
||||
// the title, "Need help?" support email below the form, "Powered
|
||||
// by PicPeak" footer line. The .customer-surface marker class
|
||||
// lets the global stylesheet retheme <Card>/<Input> for dark
|
||||
// backgrounds (admin uses the dark: trigger; customer uses theme
|
||||
// tokens).
|
||||
className="customer-surface min-h-screen flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo / header — matches AdminLoginPage's tinted square frame
|
||||
so the brand presentation is identical across admin and
|
||||
customer entry points. The frame itself is admin-controllable
|
||||
via Branding → "Show tinted frame behind login logo" — same
|
||||
toggle drives both login pages. */}
|
||||
<div className="text-center mb-8">
|
||||
{settingsData?.branding_login_logo_frame_enabled !== false ? (
|
||||
<div
|
||||
className="w-[200px] h-[150px] mx-auto mb-6 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: '#eee6d2' }}
|
||||
>
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={companyName}
|
||||
className="w-[180px] h-[130px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={companyName}
|
||||
className="h-24 w-auto object-contain mx-auto mb-6"
|
||||
/>
|
||||
)}
|
||||
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>
|
||||
{t('customer.login.title', 'Customer login')}
|
||||
</h1>
|
||||
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
{t('customer.login.subtitle', 'Access all of your photo galleries in one place.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{errors.form && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-2 p-3 rounded-lg border"
|
||||
style={{
|
||||
borderColor: 'var(--color-surface-border, #e5e5e5)',
|
||||
color: 'var(--color-text)',
|
||||
backgroundColor: 'var(--color-elevated, rgba(220, 38, 38, 0.05))',
|
||||
}}
|
||||
>
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-600" />
|
||||
<span className="text-sm">{errors.form}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="customer-email" className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.login.email', 'Email')}
|
||||
</label>
|
||||
<Input
|
||||
id="customer-email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange('email')}
|
||||
error={errors.email}
|
||||
placeholder={t('customer.login.emailPlaceholder', '[email protected]')}
|
||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="customer-password" className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.login.password', 'Password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="customer-password"
|
||||
name="current-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
placeholder={t('customer.login.passwordPlaceholder', 'Your password')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((p) => !p)}
|
||||
className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword
|
||||
? t('customer.login.hidePassword', 'Hide password')
|
||||
: t('customer.login.showPassword', 'Show password')}
|
||||
>
|
||||
{showPassword
|
||||
? <EyeOff className="w-5 h-5" />
|
||||
: <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReCaptcha
|
||||
onChange={setRecaptchaToken}
|
||||
onExpired={() => setRecaptchaToken(null)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{t('customer.login.signIn', 'Sign in')}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Footer — mirrors AdminLoginPage. Support email links to
|
||||
mailto: with the address from Branding settings; falls back
|
||||
to a placeholder so the link is never broken. The
|
||||
"admin hint" line that used to live here is gone — admins
|
||||
who land here on purpose can navigate to /admin/login on
|
||||
their own. */}
|
||||
<div className="text-center mt-8">
|
||||
<p className="text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
{t('customer.login.needHelp', 'Need help?')}{' '}
|
||||
<a
|
||||
href={`mailto:${settingsData?.branding_support_email || '[email protected]'}`}
|
||||
className="hover:underline"
|
||||
style={{ color: 'var(--color-primary, #5C8762)' }}
|
||||
>
|
||||
{settingsData?.branding_support_email || '[email protected]'}
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
|
||||
{t('customer.login.poweredBy', 'Powered by PicPeak')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerLoginPage;
|
||||
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* Customer self-service profile (#354 follow-up).
|
||||
*
|
||||
* Mounted at /customer/profile. Lets the logged-in customer edit:
|
||||
* - personal name (salutation / first / last / display)
|
||||
* - contact (phone, company, VAT id)
|
||||
* - billing address
|
||||
* - password
|
||||
*
|
||||
* The layout intentionally mirrors the admin detail pages (sectioned
|
||||
* Cards, two-column form on wide screens, save buttons inside each section
|
||||
* so a customer who only wants to fix their phone number doesn't have to
|
||||
* scroll past the address). Email is read-only here — changing the login
|
||||
* credential is admin-only for the same reason it is on AdminUserDetail.
|
||||
*
|
||||
* Two endpoints are hit:
|
||||
* - PUT /api/customer/profile (name + contact + address)
|
||||
* - POST /api/customer/profile/password (password change with re-auth)
|
||||
*
|
||||
* The password section bumps password_changed_at on the server, which
|
||||
* silently logs other browser sessions out of this customer account on
|
||||
* their next request. The current session keeps its cookie so the user
|
||||
* doesn't get bounced to login mid-flow.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Lock, Save, User as UserIcon, MapPin, Phone, Mail } from 'lucide-react';
|
||||
|
||||
import { Button, Input, Loading } from '../../components/common';
|
||||
|
||||
/**
|
||||
* Inline tile wrapper used in place of <Card> on this page.
|
||||
*
|
||||
* The global .card class (used by <Card>) hard-codes bg-white + neutral
|
||||
* borders, which fights the dark theme on the customer surface (the same
|
||||
* fix the dashboard already shipped). Pinning to the theme tokens here
|
||||
* keeps every section card consistent with the sidebar and the
|
||||
* dashboard.
|
||||
*/
|
||||
const ProfileTile: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div
|
||||
className="rounded-xl border p-6 sm:p-8"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
import {
|
||||
customerService,
|
||||
type CustomerProfileFull,
|
||||
type CustomerProfileUpdate,
|
||||
} from '../../services/customer.service';
|
||||
|
||||
const SALUTATION_OPTIONS = [
|
||||
{ value: '', labelKey: 'customer.profile.salutation.none', fallback: '— Not specified —' },
|
||||
{ value: 'Herr', labelKey: 'customer.profile.salutation.herr', fallback: 'Herr' },
|
||||
{ value: 'Frau', labelKey: 'customer.profile.salutation.frau', fallback: 'Frau' },
|
||||
{ value: 'Mx', labelKey: 'customer.profile.salutation.mx', fallback: 'Mx' },
|
||||
{ value: 'Dr', labelKey: 'customer.profile.salutation.dr', fallback: 'Dr.' },
|
||||
];
|
||||
|
||||
/** Normalise a server profile into the local form-state shape (string | ''). */
|
||||
function profileToForm(p: CustomerProfileFull): CustomerProfileUpdate {
|
||||
return {
|
||||
salutation: p.salutation ?? '',
|
||||
firstName: p.firstName ?? '',
|
||||
lastName: p.lastName ?? '',
|
||||
displayName: p.displayName ?? '',
|
||||
phone: p.phone ?? '',
|
||||
companyName: p.companyName ?? '',
|
||||
vatId: p.vatId ?? '',
|
||||
addressLine1: p.addressLine1 ?? '',
|
||||
addressLine2: p.addressLine2 ?? '',
|
||||
postalCode: p.postalCode ?? '',
|
||||
city: p.city ?? '',
|
||||
state: p.state ?? '',
|
||||
countryCode: p.countryCode ?? '',
|
||||
preferredLanguage: p.preferredLanguage ?? 'en',
|
||||
};
|
||||
}
|
||||
|
||||
export const CustomerProfilePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: profile, isLoading, error } = useQuery({
|
||||
queryKey: ['customer-profile'],
|
||||
queryFn: () => customerService.getProfile(),
|
||||
});
|
||||
|
||||
// Local form state — initialised from server profile, edited freely until
|
||||
// the user clicks Save. We keep it as a single object to make the diffing
|
||||
// for the PUT call straightforward.
|
||||
const [form, setForm] = useState<CustomerProfileUpdate>({});
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [profileErr, setProfileErr] = useState<string | null>(null);
|
||||
|
||||
// Password change is its own mini-form. Kept separate so the main save
|
||||
// doesn't accidentally sweep up half-typed password fields.
|
||||
const [pwForm, setPwForm] = useState({ current: '', next: '', confirm: '' });
|
||||
const [pwErrors, setPwErrors] = useState<Record<string, string>>({});
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile) setForm(profileToForm(profile));
|
||||
}, [profile]);
|
||||
|
||||
const updateField = (key: keyof CustomerProfileUpdate, value: string) => {
|
||||
setForm((p) => ({ ...p, [key]: value }));
|
||||
};
|
||||
|
||||
const handleProfileSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileErr(null);
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
// Send empty strings as null so the server clears the row instead of
|
||||
// storing whitespace. The backend already coerces empty strings, but
|
||||
// doing it client-side keeps the request payload honest.
|
||||
const payload: CustomerProfileUpdate = {};
|
||||
for (const [k, v] of Object.entries(form)) {
|
||||
const key = k as keyof CustomerProfileUpdate;
|
||||
payload[key] = (typeof v === 'string' && v.trim() === '') ? null : v as any;
|
||||
}
|
||||
const updated = await customerService.updateProfile(payload);
|
||||
setForm(profileToForm(updated));
|
||||
qc.invalidateQueries({ queryKey: ['customer-profile'] });
|
||||
toast.success(t('customer.profile.savedToast', 'Profile saved'));
|
||||
} catch (err: any) {
|
||||
setProfileErr(err?.response?.data?.error || t('customer.profile.saveError', 'Could not save profile.'));
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validatePassword = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!pwForm.current) {
|
||||
next.current = t('customer.profile.password.currentRequired', 'Enter your current password');
|
||||
}
|
||||
if (pwForm.next.length < 8) {
|
||||
next.next = t('customer.profile.password.tooShort', 'At least 8 characters');
|
||||
}
|
||||
if (pwForm.next !== pwForm.confirm) {
|
||||
next.confirm = t('customer.profile.password.mismatch', 'Passwords do not match');
|
||||
}
|
||||
setPwErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handlePasswordSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validatePassword()) return;
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await customerService.changePassword(pwForm.current, pwForm.next);
|
||||
setPwForm({ current: '', next: '', confirm: '' });
|
||||
setPwErrors({});
|
||||
toast.success(t('customer.profile.password.savedToast', 'Password updated'));
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 401) {
|
||||
setPwErrors({ current: t('customer.profile.password.wrong', 'Current password is incorrect') });
|
||||
} else if (status === 400 && err?.response?.data?.details?.length) {
|
||||
setPwErrors({ next: err.response.data.details.join(' ') });
|
||||
} else {
|
||||
toast.error(t('customer.profile.password.error', 'Could not change password'));
|
||||
}
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container py-12 flex justify-center">
|
||||
<Loading size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !profile) {
|
||||
return (
|
||||
<div className="container py-12">
|
||||
<ProfileTile>
|
||||
<p className="text-sm text-red-600">
|
||||
{t('customer.profile.loadError', 'Could not load your profile.')}
|
||||
</p>
|
||||
</ProfileTile>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-6 sm:py-8 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-theme">
|
||||
{t('customer.profile.title', 'Customer profile')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-theme">
|
||||
{t('customer.profile.subtitle', 'Keep your contact and billing details up to date — they\'re shown on quotes and invoices once those features go live.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Personal info + contact + address — single combined form so a
|
||||
customer can update everything in one save. The visual sections
|
||||
are inside the form purely for grouping. */}
|
||||
<form onSubmit={handleProfileSave} className="space-y-6">
|
||||
<ProfileTile>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<UserIcon className="w-5 h-5 text-muted-theme" />
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
{t('customer.profile.section.personal', 'Personal information')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.field.email', 'Email (login)')}
|
||||
</label>
|
||||
<Input
|
||||
value={profile.email}
|
||||
readOnly
|
||||
disabled
|
||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-theme">
|
||||
{t('customer.profile.field.emailHint', 'Contact your photographer if you need to change your login email.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-salutation">
|
||||
{t('customer.profile.field.salutation', 'Salutation')}
|
||||
</label>
|
||||
<select
|
||||
id="profile-salutation"
|
||||
value={form.salutation || ''}
|
||||
onChange={(e) => updateField('salutation', e.target.value)}
|
||||
className="w-full rounded-lg border px-3 h-10 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
borderColor: 'var(--color-surface-border)',
|
||||
color: 'var(--color-text)',
|
||||
}}
|
||||
>
|
||||
{SALUTATION_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{t(o.labelKey, o.fallback)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-first-name">
|
||||
{t('customer.profile.field.firstName', 'First name')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-first-name"
|
||||
name="given-name"
|
||||
autoComplete="given-name"
|
||||
value={form.firstName || ''}
|
||||
onChange={(e) => updateField('firstName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-last-name">
|
||||
{t('customer.profile.field.lastName', 'Last name')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-last-name"
|
||||
name="family-name"
|
||||
autoComplete="family-name"
|
||||
value={form.lastName || ''}
|
||||
onChange={(e) => updateField('lastName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-display-name">
|
||||
{t('customer.profile.field.displayName', 'Display name')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-display-name"
|
||||
name="nickname"
|
||||
autoComplete="nickname"
|
||||
value={form.displayName || ''}
|
||||
onChange={(e) => updateField('displayName', e.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-theme">
|
||||
{t('customer.profile.field.displayNameHint', 'How we greet you in the dashboard.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ProfileTile>
|
||||
|
||||
<ProfileTile>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Phone className="w-5 h-5 text-muted-theme" />
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
{t('customer.profile.section.contact', 'Contact & business')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-phone">
|
||||
{t('customer.profile.field.phone', 'Phone')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-phone"
|
||||
name="tel"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={form.phone || ''}
|
||||
onChange={(e) => updateField('phone', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-company">
|
||||
{t('customer.profile.field.companyName', 'Company name')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-company"
|
||||
name="organization"
|
||||
autoComplete="organization"
|
||||
value={form.companyName || ''}
|
||||
onChange={(e) => updateField('companyName', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-vat">
|
||||
{t('customer.profile.field.vatId', 'VAT ID')}
|
||||
</label>
|
||||
{/* No standard autocomplete token for VAT — leave it off so
|
||||
browsers don't try to fill it from a random saved value. */}
|
||||
<Input
|
||||
id="profile-vat"
|
||||
name="vat-id"
|
||||
value={form.vatId || ''}
|
||||
onChange={(e) => updateField('vatId', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProfileTile>
|
||||
|
||||
<ProfileTile>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MapPin className="w-5 h-5 text-muted-theme" />
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
{t('customer.profile.section.address', 'Billing address')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Address autofill: browsers (especially Safari) need three things
|
||||
to reliably fill a billing address:
|
||||
1. Each input has a `name` attribute that matches a standard
|
||||
form-autofill token (address-line1, postal-code, etc.).
|
||||
2. The corresponding `autoComplete` attribute matches the
|
||||
same token.
|
||||
3. All address fields share an autocomplete *section* — we
|
||||
prefix every token with `billing` so browser fills the
|
||||
user's billing address rather than their shipping one
|
||||
(Safari treats unprefixed and `shipping` as the default).
|
||||
Without `name`+`id` matching, Safari heuristics give up and
|
||||
fall back to nothing, which is what the maintainer hit.
|
||||
*/}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-6 gap-4">
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-address-line1">
|
||||
{t('customer.profile.field.addressLine1', 'Address line 1')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-address-line1"
|
||||
name="address-line1"
|
||||
autoComplete="billing address-line1"
|
||||
value={form.addressLine1 || ''}
|
||||
onChange={(e) => updateField('addressLine1', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-6">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-address-line2">
|
||||
{t('customer.profile.field.addressLine2', 'Address line 2')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-address-line2"
|
||||
name="address-line2"
|
||||
autoComplete="billing address-line2"
|
||||
value={form.addressLine2 || ''}
|
||||
onChange={(e) => updateField('addressLine2', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-postal-code">
|
||||
{t('customer.profile.field.postalCode', 'Postal code')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-postal-code"
|
||||
name="postal-code"
|
||||
autoComplete="billing postal-code"
|
||||
inputMode="numeric"
|
||||
value={form.postalCode || ''}
|
||||
onChange={(e) => updateField('postalCode', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-city">
|
||||
{t('customer.profile.field.city', 'City')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-city"
|
||||
name="address-level2"
|
||||
autoComplete="billing address-level2"
|
||||
value={form.city || ''}
|
||||
onChange={(e) => updateField('city', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-1">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-country">
|
||||
{t('customer.profile.field.countryCode', 'Country')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-country"
|
||||
name="country"
|
||||
autoComplete="billing country"
|
||||
placeholder="DE"
|
||||
maxLength={2}
|
||||
value={form.countryCode || ''}
|
||||
onChange={(e) => updateField('countryCode', e.target.value.toUpperCase().slice(0, 2))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-state">
|
||||
{t('customer.profile.field.state', 'State / region')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-state"
|
||||
name="address-level1"
|
||||
autoComplete="billing address-level1"
|
||||
value={form.state || ''}
|
||||
onChange={(e) => updateField('state', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProfileTile>
|
||||
|
||||
{profileErr && (
|
||||
<p className="text-sm text-red-600">{profileErr}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary" leftIcon={<Save className="w-4 h-4" />} isLoading={savingProfile}>
|
||||
{t('customer.profile.save', 'Save changes')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Password change — separate form so it doesn't fight with the main
|
||||
save button and the user's password autofill never leaks into
|
||||
unrelated fields. */}
|
||||
<ProfileTile>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Lock className="w-5 h-5 text-muted-theme" />
|
||||
<h2 className="text-lg font-semibold text-theme">
|
||||
{t('customer.profile.section.password', 'Change password')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordSave} className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.password.current', 'Current password')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={pwForm.current}
|
||||
onChange={(e) => setPwForm((p) => ({ ...p, current: e.target.value }))}
|
||||
error={pwErrors.current}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.password.next', 'New password')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={pwForm.next}
|
||||
onChange={(e) => setPwForm((p) => ({ ...p, next: e.target.value }))}
|
||||
error={pwErrors.next}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.profile.password.confirm', 'Confirm new password')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={pwForm.confirm}
|
||||
onChange={(e) => setPwForm((p) => ({ ...p, confirm: e.target.value }))}
|
||||
error={pwErrors.confirm}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<p className="mt-1 text-xs text-muted-theme">
|
||||
{t('customer.profile.password.hint', 'At least 8 characters with one uppercase letter and one number.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:col-span-3 flex justify-end">
|
||||
<Button type="submit" variant="primary" leftIcon={<Lock className="w-4 h-4" />} isLoading={savingPassword}>
|
||||
{t('customer.profile.password.submit', 'Update password')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ProfileTile>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerProfilePage;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { CustomerComingSoonPage } from './CustomerComingSoonPage';
|
||||
|
||||
export const CustomerQuotesPage: React.FC = () => (
|
||||
<CustomerComingSoonPage
|
||||
titleKey="customer.quotes.title"
|
||||
titleFallback="Quotes"
|
||||
bodyKey="customer.quotes.body"
|
||||
bodyFallback="Review and accept quotes for upcoming shoots in one place. We're still building this — for now, your photographer will keep sending quotes the usual way."
|
||||
icon={FileText}
|
||||
/>
|
||||
);
|
||||
|
||||
export default CustomerQuotesPage;
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Customer password reset (#354 follow-up).
|
||||
*
|
||||
* Mounted at /customer/reset-password/:token. Public route — anyone with
|
||||
* the link can complete the reset. The token IS the auth: 256 bits of
|
||||
* entropy, single-use, 7-day TTL, server-side validated; the existing
|
||||
* password keeps working until this page successfully POSTs a new one.
|
||||
*
|
||||
* Mirrors CustomerAcceptInvitePage's chrome (logo + branded background)
|
||||
* for visual consistency with the rest of the customer surface.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Lock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { customerService } from '../../services/customer.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
export const CustomerResetPasswordPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token = '' } = useParams<{ token: string }>();
|
||||
|
||||
const [reset, setReset] = useState<{ email: string; expiresAt: string } | null>(null);
|
||||
const [lookupError, setLookupError] = useState<string | null>(null);
|
||||
const [isLookingUp, setIsLookingUp] = useState(true);
|
||||
|
||||
const [form, setForm] = useState({ password: '', confirm: '' });
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// Pre-flight token validation. Same pattern as the invite page — if the
|
||||
// token is invalid we render an error state instead of a useless form.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLookingUp(true);
|
||||
customerService.getPasswordReset(token)
|
||||
.then((info) => {
|
||||
if (cancelled) return;
|
||||
setReset(info);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setLookupError(t(
|
||||
'customer.resetPassword.invalidToken',
|
||||
'This reset link is invalid or has expired. Please ask your photographer to send a new one.'
|
||||
));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLookingUp(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [token, t]);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (form.password.length < 8) {
|
||||
next.password = t('customer.resetPassword.tooShort', 'Password must be at least 8 characters');
|
||||
}
|
||||
if (form.password !== form.confirm) {
|
||||
next.confirm = t('customer.resetPassword.mismatch', 'Passwords do not match');
|
||||
}
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await customerService.applyPasswordReset(token, form.password);
|
||||
toast.success(t('customer.resetPassword.successToast', 'Password updated. Please log in.'));
|
||||
navigate('/customer/login?reset=1', { replace: true });
|
||||
} catch (error: any) {
|
||||
if (error.response?.data?.details?.length) {
|
||||
setErrors({ password: error.response.data.details.join(' ') });
|
||||
} else if (error.response?.status === 400) {
|
||||
setErrors({ form: error.response?.data?.error || t('customer.resetPassword.invalidSubmission', 'Could not update your password.') });
|
||||
} else {
|
||||
toast.error(t('customer.resetPassword.generalError', 'Could not update your password. Please try again.'));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="customer-surface min-h-screen flex items-center justify-center px-4 py-8"
|
||||
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}
|
||||
>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<img
|
||||
src={resolvedLogoUrl}
|
||||
alt={companyName}
|
||||
className="h-16 w-auto object-contain mx-auto mb-4"
|
||||
/>
|
||||
<h1 className="text-2xl font-bold text-theme">
|
||||
{t('customer.resetPassword.title', 'Reset your password')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
{isLookingUp ? (
|
||||
<div className="flex justify-center py-8"><Loading size="lg" /></div>
|
||||
) : lookupError || !reset ? (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<AlertCircle className="w-5 h-5 mt-0.5 flex-shrink-0 text-red-600" />
|
||||
<p className="text-theme">{lookupError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg" style={{ backgroundColor: 'var(--color-elevated, #f5f5f5)' }}>
|
||||
<CheckCircle className="w-5 h-5 mt-0.5 flex-shrink-0" style={{ color: 'var(--color-accent)' }} />
|
||||
<div className="text-sm text-theme">
|
||||
{t('customer.resetPassword.forEmail', 'Setting a new password for ')}
|
||||
<span className="font-medium">{reset.email}</span>
|
||||
{'.'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.form && (
|
||||
<div role="alert" className="flex items-start gap-2 p-3 rounded-lg border" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0 text-red-600" />
|
||||
<span className="text-sm text-theme">{errors.form}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.resetPassword.password', 'New password')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm((p) => ({ ...p, password: e.target.value }))}
|
||||
error={errors.password}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
autoComplete="new-password"
|
||||
autoFocus
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-theme">
|
||||
{t('customer.resetPassword.hint', 'At least 8 characters with one uppercase letter and one number.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customer.resetPassword.confirm', 'Confirm new password')}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.confirm}
|
||||
onChange={(e) => setForm((p) => ({ ...p, confirm: e.target.value }))}
|
||||
error={errors.confirm}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" variant="primary" size="lg" isLoading={isSubmitting} className="w-full">
|
||||
{t('customer.resetPassword.submit', 'Update password')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerResetPasswordPage;
|
||||
@@ -0,0 +1,9 @@
|
||||
export { CustomerLoginPage } from './CustomerLoginPage';
|
||||
export { CustomerDashboardPage } from './CustomerDashboardPage';
|
||||
export { CustomerAcceptInvitePage } from './CustomerAcceptInvitePage';
|
||||
export { CustomerLayout } from './CustomerLayout';
|
||||
export { CustomerProfilePage } from './CustomerProfilePage';
|
||||
export { CustomerCalendarPage } from './CustomerCalendarPage';
|
||||
export { CustomerQuotesPage } from './CustomerQuotesPage';
|
||||
export { CustomerBillsPage } from './CustomerBillsPage';
|
||||
export { CustomerResetPasswordPage } from './CustomerResetPasswordPage';
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Customer-side API client (#354).
|
||||
*
|
||||
* Strictly separate from authService.adminLogin / galleryService — uses
|
||||
* the /api/customer/* surface and the customer_token cookie. Never falls
|
||||
* back to admin endpoints.
|
||||
*/
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface CustomerProfile {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
preferredLanguage: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full self-service profile shape — superset of CustomerProfile (which is
|
||||
* the narrow auth-payload version). Used by the profile page and the
|
||||
* accept-invite form.
|
||||
*/
|
||||
export interface CustomerProfileFull extends CustomerProfile {
|
||||
salutation: string | null;
|
||||
phone: string | null;
|
||||
companyName: string | null;
|
||||
vatId: string | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
countryCode: string | null;
|
||||
}
|
||||
|
||||
/** Subset of profile fields the admin can pre-fill on an invitation
|
||||
* and that the customer can edit on accept. */
|
||||
export interface CustomerProfilePrefill {
|
||||
salutation?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
display_name?: string;
|
||||
phone?: string;
|
||||
company_name?: string;
|
||||
vat_id?: string;
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
postal_code?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country_code?: string;
|
||||
}
|
||||
|
||||
export interface CustomerEvent {
|
||||
id: number;
|
||||
slug: string;
|
||||
eventName: string;
|
||||
eventType: string;
|
||||
eventDate: string | null;
|
||||
expiresAt: string | null;
|
||||
isActive: boolean;
|
||||
assignedAt: string;
|
||||
}
|
||||
|
||||
export interface CustomerInvitationInfo {
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
invitedBy: string | null;
|
||||
/** Admin-supplied prefill — populates the accept-invite profile form. */
|
||||
prefill: CustomerProfilePrefill | null;
|
||||
}
|
||||
|
||||
export interface CustomerProfileUpdate {
|
||||
salutation?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
displayName?: string | null;
|
||||
phone?: string | null;
|
||||
companyName?: string | null;
|
||||
vatId?: string | null;
|
||||
addressLine1?: string | null;
|
||||
addressLine2?: string | null;
|
||||
postalCode?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
countryCode?: string | null;
|
||||
preferredLanguage?: string;
|
||||
}
|
||||
|
||||
export interface CustomerAccessTokenResponse {
|
||||
token: string;
|
||||
event: { id: number; slug: string; eventName: string };
|
||||
}
|
||||
|
||||
export const customerService = {
|
||||
// ---- auth ----
|
||||
async login(email: string, password: string, recaptchaToken?: string | null): Promise<{
|
||||
customer: CustomerProfile;
|
||||
features: { calendar: boolean; quotes: boolean; bills: boolean };
|
||||
branding: { showLogo: boolean; showCompanyName: boolean };
|
||||
}> {
|
||||
const response = await api.post<{
|
||||
customer: CustomerProfile;
|
||||
features?: { calendar: boolean; quotes: boolean; bills: boolean };
|
||||
branding?: { showLogo: boolean; showCompanyName: boolean };
|
||||
}>(
|
||||
'/customer/auth/login',
|
||||
{ email, password, recaptchaToken }
|
||||
);
|
||||
// Backwards-compat fallbacks for older backends that haven't been
|
||||
// upgraded yet — defaults match CustomerAuthContext's DEFAULT_*.
|
||||
return {
|
||||
customer: response.data.customer,
|
||||
features: response.data.features || { calendar: false, quotes: false, bills: false },
|
||||
branding: response.data.branding || { showLogo: true, showCompanyName: true },
|
||||
};
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await api.post('/customer/auth/logout');
|
||||
} catch (e) {
|
||||
// Logout is best-effort — the cookie clear is what matters and
|
||||
// the backend always clears it even on error.
|
||||
}
|
||||
},
|
||||
|
||||
async session(): Promise<{
|
||||
customer: CustomerProfile;
|
||||
features: { calendar: boolean; quotes: boolean; bills: boolean };
|
||||
branding: { showLogo: boolean; showCompanyName: boolean };
|
||||
} | null> {
|
||||
try {
|
||||
const response = await api.get<{
|
||||
customer: CustomerProfile;
|
||||
features?: { calendar: boolean; quotes: boolean; bills: boolean };
|
||||
branding?: { showLogo: boolean; showCompanyName: boolean };
|
||||
}>('/customer/auth/session');
|
||||
return {
|
||||
customer: response.data.customer,
|
||||
features: response.data.features || { calendar: false, quotes: false, bills: false },
|
||||
branding: response.data.branding || { showLogo: true, showCompanyName: true },
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Look up a password-reset token without consuming it. Lets the reset
|
||||
* page render "you're resetting the password for {{email}}" before
|
||||
* the customer submits.
|
||||
*/
|
||||
async getPasswordReset(token: string): Promise<{ email: string; expiresAt: string }> {
|
||||
const response = await api.get<{ reset: { email: string; expiresAt: string } }>(
|
||||
`/customer/auth/password-reset/${encodeURIComponent(token)}`,
|
||||
);
|
||||
return response.data.reset;
|
||||
},
|
||||
|
||||
/** Apply a password reset (token + new password). */
|
||||
async applyPasswordReset(token: string, password: string): Promise<{ email: string }> {
|
||||
const response = await api.post<{ email: string }>(
|
||||
'/customer/auth/password-reset',
|
||||
{ token, password },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ---- invitations ----
|
||||
async getInvitation(token: string): Promise<CustomerInvitationInfo> {
|
||||
const response = await api.get<{ invitation: CustomerInvitationInfo }>(
|
||||
`/customer/auth/invite/${encodeURIComponent(token)}`
|
||||
);
|
||||
return response.data.invitation;
|
||||
},
|
||||
|
||||
async acceptInvitation(
|
||||
token: string,
|
||||
name: string,
|
||||
password: string,
|
||||
profile?: CustomerProfilePrefill,
|
||||
): Promise<{ email: string }> {
|
||||
const response = await api.post<{ email: string }>(
|
||||
'/customer/auth/accept-invite',
|
||||
{ token, name, password, profile },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ---- profile (self-service) ----
|
||||
async getProfile(): Promise<CustomerProfileFull> {
|
||||
const response = await api.get<{ profile: CustomerProfileFull }>('/customer/profile');
|
||||
return response.data.profile;
|
||||
},
|
||||
|
||||
async updateProfile(payload: CustomerProfileUpdate): Promise<CustomerProfileFull> {
|
||||
const response = await api.put<{ profile: CustomerProfileFull }>('/customer/profile', payload);
|
||||
return response.data.profile;
|
||||
},
|
||||
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
await api.post('/customer/profile/password', { currentPassword, newPassword });
|
||||
},
|
||||
|
||||
// ---- dashboard ----
|
||||
async listEvents(): Promise<CustomerEvent[]> {
|
||||
const response = await api.get<{ events: CustomerEvent[] }>('/customer/events');
|
||||
return response.data.events;
|
||||
},
|
||||
|
||||
/**
|
||||
* Exchange the customer JWT for a gallery JWT scoped to one event.
|
||||
* The dashboard calls this on card-click and stores the resulting
|
||||
* token in the slug-specific gallery cookie via storeGalleryToken().
|
||||
*/
|
||||
async getEventAccessToken(slug: string): Promise<CustomerAccessTokenResponse> {
|
||||
const response = await api.get<CustomerAccessTokenResponse>(
|
||||
`/customer/events/${encodeURIComponent(slug)}/access-token`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* Admin → Customers API client (#354).
|
||||
*
|
||||
* Hits /api/admin/customers/* (admin auth). Distinct from customer.service.ts
|
||||
* which is the customer's own /api/customer/* surface.
|
||||
*/
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface CustomerAccountSummary {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
salutation: string | null;
|
||||
companyName: string | null;
|
||||
isActive: boolean;
|
||||
lastLogin: string | null;
|
||||
createdAt: string;
|
||||
eventCount?: number;
|
||||
/** Per-customer feature flags (#354 follow-up). */
|
||||
featureCalendar?: boolean;
|
||||
featureQuotes?: boolean;
|
||||
featureBills?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomerAccountDetail extends CustomerAccountSummary {
|
||||
phone: string | null;
|
||||
billingEmail: string | null;
|
||||
vatId: string | null;
|
||||
addressLine1: string | null;
|
||||
addressLine2: string | null;
|
||||
postalCode: string | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
countryCode: string | null;
|
||||
preferredLanguage: string;
|
||||
notes: string | null;
|
||||
events: Array<{
|
||||
id: number;
|
||||
slug: string;
|
||||
eventName: string;
|
||||
eventDate: string | null;
|
||||
expiresAt: string | null;
|
||||
isArchived: boolean;
|
||||
assignedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Optional admin-side prefill on invite — see /admin/customers/invite. */
|
||||
export interface CustomerInvitePrefill {
|
||||
salutation?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
display_name?: string;
|
||||
phone?: string;
|
||||
company_name?: string;
|
||||
vat_id?: string;
|
||||
address_line1?: string;
|
||||
address_line2?: string;
|
||||
postal_code?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country_code?: string;
|
||||
}
|
||||
|
||||
export interface CustomerInvitationSummary {
|
||||
id: number;
|
||||
email: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
invitedBy: string | null;
|
||||
}
|
||||
|
||||
export const customerAdminService = {
|
||||
async list(search?: string): Promise<CustomerAccountSummary[]> {
|
||||
const response = await api.get<{ customers: CustomerAccountSummary[] }>(
|
||||
'/admin/customers',
|
||||
{ params: search ? { search } : undefined }
|
||||
);
|
||||
return response.data.customers;
|
||||
},
|
||||
|
||||
async search(term: string): Promise<CustomerAccountSummary[]> {
|
||||
if (!term || !term.trim()) return [];
|
||||
const response = await api.get<{ customers: CustomerAccountSummary[] }>(
|
||||
'/admin/customers/search',
|
||||
{ params: { email: term } }
|
||||
);
|
||||
return response.data.customers;
|
||||
},
|
||||
|
||||
async get(id: number): Promise<CustomerAccountDetail> {
|
||||
const response = await api.get<{ customer: CustomerAccountDetail }>(`/admin/customers/${id}`);
|
||||
return response.data.customer;
|
||||
},
|
||||
|
||||
async update(id: number, payload: Partial<Omit<CustomerAccountDetail, 'id' | 'events' | 'eventCount'>>): Promise<CustomerAccountDetail> {
|
||||
// Frontend sends camelCase, backend accepts snake_case — translate here
|
||||
// so callers can stay in TS-land conventions.
|
||||
const snake: Record<string, any> = {};
|
||||
const map: Record<string, string> = {
|
||||
email: 'email',
|
||||
salutation: 'salutation',
|
||||
firstName: 'first_name',
|
||||
lastName: 'last_name',
|
||||
displayName: 'display_name',
|
||||
phone: 'phone',
|
||||
companyName: 'company_name',
|
||||
billingEmail: 'billing_email',
|
||||
vatId: 'vat_id',
|
||||
addressLine1: 'address_line1',
|
||||
addressLine2: 'address_line2',
|
||||
postalCode: 'postal_code',
|
||||
city: 'city',
|
||||
state: 'state',
|
||||
countryCode: 'country_code',
|
||||
preferredLanguage: 'preferred_language',
|
||||
notes: 'notes',
|
||||
isActive: 'is_active',
|
||||
// Per-customer feature flags (#354 follow-up).
|
||||
featureCalendar: 'feature_calendar',
|
||||
featureQuotes: 'feature_quotes',
|
||||
featureBills: 'feature_bills',
|
||||
};
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (k in map) snake[map[k]] = v;
|
||||
}
|
||||
const response = await api.put<{ customer: CustomerAccountDetail }>(`/admin/customers/${id}`, snake);
|
||||
return response.data.customer;
|
||||
},
|
||||
|
||||
async deactivate(id: number): Promise<void> {
|
||||
await api.post(`/admin/customers/${id}/deactivate`);
|
||||
},
|
||||
|
||||
/** Restore a deactivated customer (login re-enabled, assignments stay). */
|
||||
async reactivate(id: number): Promise<void> {
|
||||
await api.post(`/admin/customers/${id}/reactivate`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Anonymize-in-place erasure (GDPR style). Customer row stays for
|
||||
* audit FKs but every PII column is nulled and credentials are wiped.
|
||||
* See backend service `eraseCustomer` for the full contract.
|
||||
*/
|
||||
async erase(id: number): Promise<void> {
|
||||
await api.post(`/admin/customers/${id}/erase`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger a password reset for an existing customer. The backend
|
||||
* generates a 7-day single-use token and emails the customer.
|
||||
*/
|
||||
async sendPasswordReset(id: number): Promise<{ email: string; expiresAt: string }> {
|
||||
const response = await api.post<{ data: { email: string; expiresAt: string } } | { email: string; expiresAt: string }>(
|
||||
`/admin/customers/${id}/password-reset`,
|
||||
);
|
||||
return ((response.data as any).data ?? response.data) as { email: string; expiresAt: string };
|
||||
},
|
||||
|
||||
/**
|
||||
* Invite a customer. `prefill` is an optional set of profile fields the
|
||||
* admin can pre-populate on the invitation row — the customer sees them
|
||||
* pre-filled (and editable) on the accept form. Saves the customer typing
|
||||
* for the common case where the photographer already has the wedding
|
||||
* couple's name + address from the booking form.
|
||||
*/
|
||||
async invite(
|
||||
email: string,
|
||||
prefill?: CustomerInvitePrefill,
|
||||
): Promise<{ id: number; email: string; expiresAt: string }> {
|
||||
const response = await api.post<{ data: { invitation: { id: number; email: string; expiresAt: string } } }>(
|
||||
'/admin/customers/invite',
|
||||
{ email, prefill },
|
||||
);
|
||||
return (response.data as any).data?.invitation ?? (response.data as any).invitation;
|
||||
},
|
||||
|
||||
async listInvitations(): Promise<CustomerInvitationSummary[]> {
|
||||
const response = await api.get<{ invitations: CustomerInvitationSummary[] }>('/admin/customers/invitations');
|
||||
return response.data.invitations;
|
||||
},
|
||||
|
||||
async cancelInvitation(id: number): Promise<void> {
|
||||
await api.delete(`/admin/customers/invitations/${id}`);
|
||||
},
|
||||
};
|
||||
@@ -41,6 +41,10 @@ interface CreateEventData {
|
||||
show_feedback_to_guests?: boolean;
|
||||
photo_cap?: number | null;
|
||||
default_photo_sort?: string;
|
||||
// Customer accounts assigned to this event (#354). Optional array of
|
||||
// customer_accounts.id; backend service diffs against the existing
|
||||
// assignments and applies inserts/deletes inside the same transaction.
|
||||
customer_account_ids?: number[];
|
||||
}
|
||||
|
||||
interface UpdateEventData {
|
||||
@@ -62,6 +66,9 @@ interface UpdateEventData {
|
||||
external_path?: string | null;
|
||||
photo_cap?: number | null;
|
||||
default_photo_sort?: string;
|
||||
// Customer accounts (#354). Same semantics as on CreateEventData;
|
||||
// omit the field to leave assignments untouched, send [] to clear.
|
||||
customer_account_ids?: number[];
|
||||
}
|
||||
|
||||
export type EventStatusFilter = 'active' | 'inactive' | 'archived' | 'draft' | 'expiring';
|
||||
|
||||
@@ -9,7 +9,14 @@ export type FeatureKey =
|
||||
| 'bills'
|
||||
| 'messaging'
|
||||
| 'analytics'
|
||||
| 'userManagement';
|
||||
| 'userManagement'
|
||||
// Foundation flag for the customer-side surface (#354). Gates the
|
||||
// /customer/* routes (login, dashboard, profile, accept-invite,
|
||||
// reset-password) and the admin Customers management page. The
|
||||
// calendar / calendarBooking / quotes / bills / messaging flags
|
||||
// above hang off this — they only appear in the customer dashboard
|
||||
// when customerPortal is also ON.
|
||||
| 'customerPortal';
|
||||
|
||||
export type FeatureFlags = Record<FeatureKey, boolean>;
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ export { cmsService } from './cms.service';
|
||||
export { notificationsService } from './notifications.service';
|
||||
export { feedbackService } from './feedback.service';
|
||||
export { userManagementService } from './userManagement.service';
|
||||
export { customerService } from './customer.service';
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Customer portal end-to-end flow (#354).
|
||||
*
|
||||
* Covers the maintainer-flagged "core promise" of the feature:
|
||||
* a customer can log in once and open every assigned gallery without
|
||||
* re-entering the per-event password. Specifically:
|
||||
*
|
||||
* 1. Admin enables the Customer dashboard (Settings → Advanced features).
|
||||
* 2. Admin creates an event AND invites a customer to that event.
|
||||
* 3. Customer accepts the invitation (sets a password).
|
||||
* 4. Customer logs in.
|
||||
* 5. Customer's dashboard lists the assigned gallery.
|
||||
* 6. Customer clicks the gallery → lands on /gallery/<slug> WITHOUT
|
||||
* seeing a password prompt. The grid renders with photos.
|
||||
*
|
||||
* Side checks:
|
||||
* - With the master toggle OFF, /customer/login redirects to /admin/login
|
||||
* (frontend gate) and the customer-side API returns 410 Gone (backend
|
||||
* gate). This is the kill-switch contract.
|
||||
*
|
||||
* Hits the API directly for setup (admin login, event create, photo upload,
|
||||
* customer invite, accept-invite, gallery assignment) and only uses the
|
||||
* browser for the parts that genuinely need the SPA: the gallery handoff
|
||||
* itself, where the bug surface lives. Keeps the spec fast and avoids
|
||||
* DOM-fragility on every admin form field.
|
||||
*/
|
||||
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'CustomerPortalGallery!1';
|
||||
const CUSTOMER_PASSWORD = 'CustomerPortalUser!1';
|
||||
|
||||
async function adminLogin(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const json = await res.json();
|
||||
expect(json.token).toBeTruthy();
|
||||
return json.token;
|
||||
}
|
||||
|
||||
async function setCustomerPortalEnabled(page: Page, adminToken: string, enabled: boolean) {
|
||||
const res = await page.request.put('/api/admin/settings/advanced-features', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: { customer_portal_enabled: enabled },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function createEventWithPhoto(page: Page, adminToken: string) {
|
||||
const eventName = `Customer Portal E2E ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const createRes = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Customer Portal Host',
|
||||
customer_email: '[email protected]',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!createRes.ok()) {
|
||||
throw new Error(`Event create failed: ${createRes.status()} ${await createRes.text()}`);
|
||||
}
|
||||
const event = await createRes.json();
|
||||
|
||||
// One photo so the gallery grid has something to render after the
|
||||
// customer hits it. Tests that the gallery loads, not that it's empty.
|
||||
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
multipart: {
|
||||
photos: { name: 'img1.png', mimeType: 'image/png', buffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(uploadRes.ok()).toBeTruthy();
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a customer, accept the invitation, return the email.
|
||||
*
|
||||
* The admin invite response echoes `invitation.token` only when
|
||||
* NODE_ENV !== 'production' — that lets the spec skip the email
|
||||
* round-trip without needing a separate /admin/email-queue endpoint
|
||||
* or direct DB access. In production the token stays email-only.
|
||||
*/
|
||||
async function inviteAndAcceptCustomer(page: Page, adminToken: string) {
|
||||
const email = `customer-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.test`;
|
||||
|
||||
const inviteRes = await page.request.post('/api/admin/customers/invite', {
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
data: { email },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!inviteRes.ok()) {
|
||||
throw new Error(`Customer invite failed: ${inviteRes.status()} ${await inviteRes.text()}`);
|
||||
}
|
||||
const inviteBody = await inviteRes.json();
|
||||
// successResponse wraps in { success, data } — accept either shape.
|
||||
const invitation = inviteBody.data?.invitation ?? inviteBody.invitation;
|
||||
expect(invitation?.token, 'expected invitation.token echoed from non-prod /invite response').toBeTruthy();
|
||||
const token = invitation.token;
|
||||
|
||||
// Accept the invitation as the customer (no auth).
|
||||
const acceptRes = await page.request.post('/api/customer/auth/accept-invite', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
token,
|
||||
name: 'Customer Portal E2E',
|
||||
password: CUSTOMER_PASSWORD,
|
||||
},
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!acceptRes.ok()) {
|
||||
throw new Error(`Accept failed: ${acceptRes.status()} ${await acceptRes.text()}`);
|
||||
}
|
||||
|
||||
return { email };
|
||||
}
|
||||
|
||||
async function getCustomerIdByEmail(page: Page, adminToken: string, email: string): Promise<number> {
|
||||
const res = await page.request.get(`/api/admin/customers?search=${encodeURIComponent(email)}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
const list = body.customers || body.data?.customers || body;
|
||||
const match = (Array.isArray(list) ? list : []).find((c: any) => c.email === email);
|
||||
expect(match, `expected customer with email ${email}`).toBeTruthy();
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async function assignCustomerToEvent(page: Page, adminToken: string, eventId: number, customerId: number) {
|
||||
// Update the event to include this customer's id in customer_account_ids
|
||||
const res = await page.request.put(`/api/admin/events/${eventId}`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}`, 'Content-Type': 'application/json' },
|
||||
data: { customer_account_ids: [customerId] },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(`Customer assignment failed: ${res.status()} ${await res.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the actual spec ---------------------------------------------------
|
||||
|
||||
test.describe('Customer portal — login + gallery handoff', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Make sure each test starts with a clean cookie jar so a leftover
|
||||
// admin_token from a previous spec doesn't accidentally satisfy
|
||||
// /api/customer/auth/session via the Authorization-Bearer fallback
|
||||
// (which the customer-side specifically refuses, but the test
|
||||
// shouldn't rely on that to pass).
|
||||
await page.context().clearCookies();
|
||||
});
|
||||
|
||||
test('customer can log in and open an assigned gallery without a password', async ({ page }) => {
|
||||
// === setup (admin side) ===
|
||||
const adminToken = await adminLogin(page);
|
||||
await setCustomerPortalEnabled(page, adminToken, true);
|
||||
|
||||
const event = await createEventWithPhoto(page, adminToken);
|
||||
const { email } = await inviteAndAcceptCustomer(page, adminToken);
|
||||
const customerId = await getCustomerIdByEmail(page, adminToken, email);
|
||||
await assignCustomerToEvent(page, adminToken, event.id, customerId);
|
||||
|
||||
try {
|
||||
// === customer flow ===
|
||||
// Login through the SPA (covers the cookie + setSession path that
|
||||
// makes the dashboard render the assigned gallery on first paint).
|
||||
await page.context().clearCookies();
|
||||
await page.goto('/customer/login');
|
||||
await page.getByLabel(/Email/i).fill(email);
|
||||
await page.getByLabel(/Password/i).fill(CUSTOMER_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign in/i }).click();
|
||||
|
||||
// Dashboard should list the assigned event by name.
|
||||
await expect(page.getByRole('heading', { name: /Your galleries/i })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText(event.event_name, { exact: false })).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click → gallery handoff. Watch for the URL change AND the absence
|
||||
// of the per-event password prompt. Either of those failing is the
|
||||
// primary regression this spec is designed to catch.
|
||||
const navPromise = page.waitForURL(/\/gallery\//, { timeout: 15000 });
|
||||
await page.getByRole('button', { name: /Open gallery/i }).first().click();
|
||||
await navPromise;
|
||||
|
||||
// The password prompt would render `Enter Gallery Password` (heading
|
||||
// or section label). It must NOT be present after the customer
|
||||
// dashboard handoff.
|
||||
await expect(page.getByText(/Enter Gallery Password/i)).toHaveCount(0);
|
||||
|
||||
// The grid tiles use the `.relative.group` selector across layouts;
|
||||
// matches `auth-smoke.spec.ts`. At least one must render.
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
} finally {
|
||||
// Clean up: turn the feature off so the next test starts from a
|
||||
// known state. Errors are swallowed — leftover state from a failed
|
||||
// run is something to investigate manually.
|
||||
await setCustomerPortalEnabled(page, adminToken, false).catch(() => { /* noop */ });
|
||||
}
|
||||
});
|
||||
|
||||
test('disabling the master toggle redirects /customer/login to /admin/login and refuses the API', async ({ page }) => {
|
||||
// Verifies the kill-switch contract on both sides:
|
||||
// - frontend: CustomerPortalGate redirects when public-settings says off
|
||||
// - backend: /api/customer/auth/session returns 410 Gone with code
|
||||
// CUSTOMER_PORTAL_DISABLED
|
||||
const adminToken = await adminLogin(page);
|
||||
await setCustomerPortalEnabled(page, adminToken, false);
|
||||
|
||||
// API gate: 410 Gone (the chosen status for "feature was here, admin
|
||||
// turned it off" — distinct from a generic 403).
|
||||
const apiRes = await page.request.get('/api/customer/auth/session', { failOnStatusCode: false });
|
||||
expect(apiRes.status()).toBe(410);
|
||||
const body = await apiRes.json().catch(() => ({}));
|
||||
expect(body.code).toBe('CUSTOMER_PORTAL_DISABLED');
|
||||
|
||||
// Frontend gate: CustomerPortalGate redirects /customer/* away to
|
||||
// /admin/login. Use waitForURL so we don't race the React Router
|
||||
// <Navigate>.
|
||||
await page.goto('/customer/login');
|
||||
await page.waitForURL(/\/admin\/login/, { timeout: 10000 });
|
||||
expect(page.url()).toContain('/admin/login');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user