diff --git a/backend/server.js b/backend/server.js index 5048b4a1..dd0d9ee2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -568,30 +568,36 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport')); app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); app.use('/api/admin/events', require('./src/routes/adminEventRename')); app.use('/api/admin/users', require('./src/routes/adminUsers')); -// Customer portal (#354). The customerPortal feature flag is enforced -// in TWO places: +// Customer portal (#354). The customerPortal feature flag is a +// VISIBILITY toggle for the admin surface, not a kill switch for +// customer access. Enforcement: +// // 1. Frontend: RequireFeature guards + AdminSidebar visibility -// (handles navigation cleanly when an admin is using the app). -// 2. Backend: the requireCustomerPortalEnabled middleware below. -// Belt-and-braces — a stale tab, a saved bookmark, or any -// third-party API client trying to hit /api/customer/* or -// /api/admin/customers/* gets a 410 Gone the moment the toggle -// is flipped off. Includes /api/customer/auth/login: flag off -// = nobody can log in until the admin re-enables, including -// already-issued customers (their sessions still have valid -// JWTs but every API call returns 410 → frontend boots them -// out). PR #458 deliberate departure from the prior design -// that left login alive when the rest of the surface was off. -const { - requireCustomerPortalEnabled, - requireCustomerPortalEnabledAdmin, -} = require('./src/middleware/requireCustomerPortal'); - -app.use('/api/admin/customers', requireCustomerPortalEnabledAdmin, require('./src/routes/adminCustomers')); +// hide the Clients section when the flag is off. Customer-side +// /customer/* surfaces stay reachable. +// 2. Backend: NO route-level gate. The admin surface is gated by +// adminAuth + permission checks (admin still has rights to +// manage customer records even if the section is hidden in +// their UI). The customer surface is gated by customerAuth + +// is_active checks on customer_accounts. +// +// For close-to-realtime access changes use the dedicated tools: +// - Revoke a customer's access to ONE gallery → "Manage galleries" +// dialog removes the event_customer_assignments row, which +// verifyGalleryAccess re-checks on every customer-minted JWT. +// - Lock out a customer entirely → "Deactivate" sets is_active=false +// and bumps password_changed_at, killing every outstanding JWT. +// - Toggle per-customer feature surfaces (calendar/quotes/bills) +// → toggles on the customer detail page. +// +// Putting the global flag in the kill-switch role was a mistake — a +// stray click in Settings → Features would lock every paying +// customer out at once. PR-revert moved the gate back to per-record. +app.use('/api/admin/customers', require('./src/routes/adminCustomers')); // Customer-side surface (#354). Strictly separate from /api/admin/* — // distinct token type, distinct cookie, distinct middleware. -app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth')); -app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer')); +app.use('/api/customer/auth', require('./src/routes/customerAuth')); +app.use('/api/customer', require('./src/routes/customer')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens')); app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks')); diff --git a/backend/src/middleware/requireCustomerPortal.js b/backend/src/middleware/requireCustomerPortal.js deleted file mode 100644 index 5b307e71..00000000 --- a/backend/src/middleware/requireCustomerPortal.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Customer portal feature-flag gate. - * - * Blocks every /api/customer/* and /api/admin/customers/* endpoint - * when the `customerPortal` flag is off. Returns 410 Gone so the - * frontend can distinguish "feature has been disabled" from "you - * don't have access" (which would be 403) — useful for the customer - * dashboard's auto-redirect on a soft-kill scenario. - * - * Reads the flag via customerAccountsService.isCustomerPortalEnabled - * (which itself reads from the maintainer's feature_flags table), - * so a single source of truth. - */ - -const customerAccountsService = require('../services/customerAccountsService'); -const logger = require('../utils/logger'); - -async function isEnabled() { - try { - return await customerAccountsService.isCustomerPortalEnabled(); - } catch (err) { - // Defensive: if the lookup throws (DB unavailable, table missing - // mid-migration), fail closed so an enabled-by-default fallback - // can't accidentally expose customer surfaces during boot. - logger.warn('requireCustomerPortal: feature flag lookup failed, treating as off', { - error: err?.message, - }); - return false; - } -} - -/** - * Customer-facing endpoints. Returns 410 with a code the frontend - * can interpret to clear stale session storage + redirect to - * /admin/login. - */ -async function requireCustomerPortalEnabled(req, res, next) { - if (await isEnabled()) return next(); - return res.status(410).json({ - error: 'Customer portal is disabled', - code: 'CUSTOMER_PORTAL_DISABLED', - }); -} - -/** - * Admin-facing /api/admin/customers/* endpoints. Same gate, same - * status code — keeps the contract consistent across both halves of - * the customer-portal surface. The sidebar UI already hides the - * entry, but a stale tab or direct API call must also be blocked. - */ -async function requireCustomerPortalEnabledAdmin(req, res, next) { - if (await isEnabled()) return next(); - return res.status(410).json({ - error: 'Customer portal is disabled', - code: 'CUSTOMER_PORTAL_DISABLED', - }); -} - -module.exports = { - requireCustomerPortalEnabled, - requireCustomerPortalEnabledAdmin, -}; diff --git a/backend/src/routes/customerAuth.js b/backend/src/routes/customerAuth.js index 87eb7b4b..8de968d8 100644 --- a/backend/src/routes/customerAuth.js +++ b/backend/src/routes/customerAuth.js @@ -48,15 +48,20 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens // ---- login ------------------------------------------------------------- -// Flag-gate note: this route IS now gated by the customerPortal feature -// flag via the requireCustomerPortalEnabled middleware mounted in -// server.js (`app.use('/api/customer/auth', requireCustomerPortalEnabled, …)`). -// When the admin flips the toggle off in Settings → Features, every -// customer-side endpoint — including login — returns 410. The previous -// design left login reachable while the rest of the surface was gated; -// that was confusing and asymmetric. Single source of truth wins. -// To lock out a specific customer without disabling the feature for -// everyone, deactivate the account (customer_accounts.is_active = false). +// The customerPortal feature flag deliberately does NOT gate this route. +// Flipping the master toggle off in Settings → Features hides the +// admin-side Clients section (sidebar entry, /admin/clients pages) but +// must not revoke access for customers who already accepted an +// invitation — that would mean a stray click in the Features tab +// locks every paying customer out at once. +// +// To revoke access at the customer level, use the per-record tools: +// - "Deactivate" on the customer detail page → sets +// customer_accounts.is_active = false AND bumps password_changed_at, +// which customerAuth rejects below + on every protected route. +// - "Manage galleries" dialog → removes event_customer_assignments +// rows, which verifyGalleryAccess re-checks on customer-minted +// gallery JWTs (instant per-gallery revocation). router.post('/login', [ body('email').isEmail().normalizeEmail().withMessage('Valid email is required'), body('password').isString().notEmpty(),