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:
@@ -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