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)
|
||||
|
||||
Reference in New Issue
Block a user