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,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 });
|
||||
@@ -664,7 +668,28 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
const eventId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
|
||||
// Apply customer-account assignments (#354). Skip when the customer
|
||||
// portal flag is off — the frontend hides the picker in that case,
|
||||
// but a stale tab could still POST customer_account_ids; we ignore
|
||||
// them rather than 403 the entire create.
|
||||
if (Array.isArray(req.body.customer_account_ids)) {
|
||||
try {
|
||||
const customerAccountsService = require('../services/customerAccountsService');
|
||||
if (await customerAccountsService.isCustomerPortalEnabled()) {
|
||||
await customerAccountsService.setAssignmentsForEvent(
|
||||
eventId,
|
||||
req.body.customer_account_ids,
|
||||
req.admin.id
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to set customer assignments on event create', {
|
||||
eventId, error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Insert feedback settings if feedback is enabled
|
||||
if (feedback_enabled) {
|
||||
await db('event_feedback_settings').insert({
|
||||
@@ -943,6 +968,17 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
|
||||
.where('event_id', id)
|
||||
.countDistinct('ip_address as uniqueVisitors');
|
||||
|
||||
// Customer accounts assigned to this event (#354). Hydrates the
|
||||
// CustomerAccountPicker on the EventDetailsPage admin form. Returns
|
||||
// an empty array on installs missing the table (e.g. pre-migrate).
|
||||
let customerAccounts = [];
|
||||
try {
|
||||
const customerAccountsService = require('../services/customerAccountsService');
|
||||
customerAccounts = await customerAccountsService.getAssignmentsForEvent(parseInt(id, 10));
|
||||
} catch (e) {
|
||||
logger.warn('Failed to load customer assignments for event', { eventId: id, error: e.message });
|
||||
}
|
||||
|
||||
res.json(mapEventForApi({
|
||||
...event,
|
||||
photo_count: parseInt(photoCount) || 0,
|
||||
@@ -950,7 +986,14 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
|
||||
total_views: parseInt(totalViews) || 0,
|
||||
total_downloads: parseInt(totalDownloads) || 0,
|
||||
unique_visitors: parseInt(uniqueVisitors) || 0,
|
||||
recent_photos: recentPhotos
|
||||
recent_photos: recentPhotos,
|
||||
customer_accounts: customerAccounts.map((c) => ({
|
||||
id: c.id,
|
||||
email: c.email,
|
||||
display_name: c.display_name,
|
||||
first_name: c.first_name,
|
||||
last_name: c.last_name,
|
||||
})),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching event:', error);
|
||||
@@ -1112,7 +1155,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
||||
// custom → render this event's promo_markdown verbatim
|
||||
// off → suppress entirely for this event
|
||||
body('promo_mode').optional().isIn(['inherit', 'custom', 'off']),
|
||||
body('promo_markdown').optional({ nullable: true }).isString()
|
||||
body('promo_markdown').optional({ nullable: true }).isString(),
|
||||
// Customer accounts assigned to this event (#354). Optional array of
|
||||
// customer_accounts.id — many-to-many via event_customer_assignments.
|
||||
body('customer_account_ids').optional().isArray(),
|
||||
body('customer_account_ids.*').optional().isInt({ min: 1 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -1313,6 +1360,26 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
|
||||
.where('id', id)
|
||||
.update(updates);
|
||||
|
||||
// Customer-account assignments (#354). Same skip semantics as POST:
|
||||
// ignore when the customer portal flag is off so stale tabs don't
|
||||
// 4xx the whole edit.
|
||||
if (Array.isArray(req.body.customer_account_ids)) {
|
||||
try {
|
||||
const customerAccountsService = require('../services/customerAccountsService');
|
||||
if (await customerAccountsService.isCustomerPortalEnabled()) {
|
||||
await customerAccountsService.setAssignmentsForEvent(
|
||||
parseInt(id, 10),
|
||||
req.body.customer_account_ids,
|
||||
req.admin.id
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to set customer assignments on event update', {
|
||||
eventId: id, error: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_updated',
|
||||
{ changes: Object.keys(updates), eventName: event.event_name },
|
||||
|
||||
@@ -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)
|
||||
@@ -19,26 +33,37 @@ async function revokeToken(token, reason, metadata = {}) {
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid token format');
|
||||
}
|
||||
|
||||
|
||||
// Decode payload
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
|
||||
|
||||
// user_id is integer-typed in revoked_tokens; for non-admin tokens
|
||||
// we may not have an integer (customer) or any id at all (gallery
|
||||
// tokens use eventId). Coerce to null instead of letting an
|
||||
// undefined/string slip through and cause an INSERT type error.
|
||||
const userIdNumeric = Number.isInteger(payload.id) ? payload.id : null;
|
||||
|
||||
// onConflict.ignore: revoking an already-revoked token is a no-op,
|
||||
// not an error. Hits the unique (token_id) index when the same JWT
|
||||
// is logged out twice (e.g. duplicate /logout from two tabs, or a
|
||||
// session-expiry path that races with an explicit logout). The
|
||||
// previous insert was authoritative; nothing to do.
|
||||
await db('revoked_tokens').insert({
|
||||
token_id: payload.jti || `${payload.id}-${payload.iat}`, // JWT ID or fallback
|
||||
user_id: payload.id,
|
||||
token_id: buildTokenId(payload),
|
||||
user_id: userIdNumeric,
|
||||
token_type: payload.type,
|
||||
revoked_at: new Date().toISOString(),
|
||||
expires_at: new Date(payload.exp * 1000).toISOString(),
|
||||
reason,
|
||||
metadata: JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
}).onConflict('token_id').ignore();
|
||||
|
||||
logger.info('Token revoked', {
|
||||
userId: payload.id,
|
||||
userId: payload.id ?? payload.customerId ?? null,
|
||||
tokenType: payload.type,
|
||||
reason
|
||||
});
|
||||
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to revoke token', error);
|
||||
@@ -53,7 +78,7 @@ async function revokeToken(token, reason, metadata = {}) {
|
||||
*/
|
||||
async function isTokenRevoked(decodedToken) {
|
||||
try {
|
||||
const tokenId = decodedToken.jti || `${decodedToken.id}-${decodedToken.iat}`;
|
||||
const tokenId = buildTokenId(decodedToken);
|
||||
|
||||
const revoked = await db('revoked_tokens')
|
||||
.where('token_id', tokenId)
|
||||
|
||||
+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,18 +188,44 @@ function clearGalleryAuthCookies(res, slug) {
|
||||
}
|
||||
|
||||
function getAdminTokenFromRequest(req) {
|
||||
const header = req.headers?.authorization;
|
||||
if (header && header.startsWith('Bearer ')) {
|
||||
return header.substring(7);
|
||||
}
|
||||
// Honour Authorization: Bearer only if the JWT claims type:'admin'.
|
||||
// Stops gallery/customer tokens that happen to be on the request from
|
||||
// being mistaken for admin auth (mirrors getGalleryTokenFromRequest's
|
||||
// protection in the other direction).
|
||||
const bearer = getBearerTokenIfType(req, 'admin');
|
||||
if (bearer) return bearer;
|
||||
return req.cookies?.[ADMIN_COOKIE_NAME] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-side equivalent. Cookie-only — we deliberately do NOT honour
|
||||
* the Authorization: Bearer header on /api/customer/* endpoints.
|
||||
*
|
||||
* Reason: admins occasionally hit the customer routes from the same
|
||||
* browser (e.g. while dogfooding the dashboard). The shared axios
|
||||
* client picks up the admin's token from `admin_token` and attaches it
|
||||
* as `Authorization: Bearer <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) {
|
||||
const header = req.headers?.authorization;
|
||||
if (header && header.startsWith('Bearer ')) {
|
||||
return header.substring(7);
|
||||
}
|
||||
// Honour Authorization: Bearer only if the JWT claims type:'gallery'.
|
||||
// The previous unconditional Bearer pickup defeated the prefer-gallery
|
||||
// precedence fix on /auth/session?slug=…: an admin token attached as
|
||||
// Bearer (e.g. by the shared events.service.ts auto-auth path) was
|
||||
// returned here, decoded as type:'admin' downstream, and mis-rendered
|
||||
// the gallery as "logged in as admin" which kicked the customer back
|
||||
// to the per-event password prompt.
|
||||
const bearer = getBearerTokenIfType(req, 'gallery');
|
||||
if (bearer) return bearer;
|
||||
|
||||
if (!req.cookies) {
|
||||
return null;
|
||||
@@ -209,12 +281,16 @@ module.exports = {
|
||||
GALLERY_COOKIE_NAME,
|
||||
GALLERY_COOKIE_PREFIX,
|
||||
GUEST_COOKIE_PREFIX,
|
||||
CUSTOMER_COOKIE_NAME,
|
||||
sanitizeSlugForCookie,
|
||||
setAdminAuthCookie,
|
||||
clearAdminAuthCookie,
|
||||
setCustomerAuthCookie,
|
||||
clearCustomerAuthCookie,
|
||||
setGalleryAuthCookies,
|
||||
clearGalleryAuthCookies,
|
||||
getAdminTokenFromRequest,
|
||||
getCustomerTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
getGuestTokenFromRequest,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user