Merge pull request #458 from Luca-Timo/fix/customer-functions

fix(customer-portal): post-merge fixes for event save, theme fonts, and customer→gallery handoff
This commit is contained in:
Paul Nothaft
2026-05-11 20:07:11 +02:00
committed by GitHub
37 changed files with 1012 additions and 187 deletions
@@ -0,0 +1,38 @@
/**
* Migration: Add the `clients` top-level feature flag.
*
* Introduces a parent flag for the "Clients" sidebar section, which
* groups customer accounts today and will host calendar / quotes /
* bills / messaging in future PRs. The existing `customerPortal` flag
* is unchanged and continues to gate the /customer/* surface plus the
* Accounts sub-page; it now lives logically beneath `clients` in the
* Features tab.
*
* Initial value: mirrors the install's current `customerPortal` value
* so an admin who had the customer portal enabled keeps seeing the
* Clients sidebar entry after upgrade, and an admin who had it off
* doesn't suddenly see a new sidebar entry.
*
* Idempotent: re-runs are no-ops.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
const existing = await knex('feature_flags').where({ key: 'clients' }).first();
if (existing) return;
const portalRow = await knex('feature_flags').where({ key: 'customerPortal' }).first();
let initialValue = false;
if (portalRow) {
const raw = portalRow.value;
initialValue = raw === true || raw === 1 || raw === '1' || raw === 'true';
}
await knex('feature_flags').insert({ key: 'clients', value: initialValue });
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('feature_flags'))) return;
await knex('feature_flags').where({ key: 'clients' }).del();
};
+21 -10
View File
@@ -569,18 +569,29 @@ 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
// on the frontend via <RequireFeature flag="customerPortal" /> route
// guards (App.tsx) and AdminSidebar visibility — when the flag is off,
// users never reach these endpoints. Defence in depth is provided by
// customerAccountsService.isCustomerPortalEnabled() in the few backend
// paths that matter (e.g. adminEvents customer_account_ids handling).
// Admin routes are protected by adminAuth; customer routes by
// customerAuth — so no additional route-level gate is needed.
app.use('/api/admin/customers', require('./src/routes/adminCustomers'));
// in TWO places:
// 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'));
// Customer-side surface (#354). Strictly separate from /api/admin/* —
// distinct token type, distinct cookie, distinct middleware.
app.use('/api/customer/auth', require('./src/routes/customerAuth'));
app.use('/api/customer', require('./src/routes/customer'));
app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth'));
app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
@@ -0,0 +1,62 @@
/**
* 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,
};
+7
View File
@@ -1262,6 +1262,13 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwne
}
delete updates.regenerate_client_token;
// customer_account_ids (#354) is a body-only field consumed
// separately below by customerAccountsService.setAssignmentsForEvent
// — it isn't a column on the events table, so spreading it into
// the UPDATE statement throws "column does not exist" and crashes
// the entire edit with 500 Failed to update event.
delete updates.customer_account_ids;
// Log the update request for debugging
logger.debug('Update event request', {
id,
+24 -3
View File
@@ -32,8 +32,14 @@ const KNOWN_FLAGS = [
'messaging',
'analytics',
'userManagement',
// Foundation flag for the customer-side surface (#354). See migration
// 094 for the seeding rule.
// Top-level "Clients" section (#354 follow-up). Parent flag that
// gates the /admin/clients/* sidebar entry. customerPortal,
// calendar, quotes, bills and messaging are conceptually its
// children — when `clients` is off none of them surface in the
// admin UI even if their individual flags are on.
'clients',
// Customer-side portal surface (#354). Gates /customer/* routes
// and the Accounts sub-page under Clients. See migration 095.
'customerPortal',
];
@@ -49,6 +55,7 @@ const DEFAULT_FLAGS = {
messaging: false,
analytics: true,
userManagement: true,
clients: false,
};
async function readAllFlags() {
@@ -69,13 +76,27 @@ function applyDependencyRules(flags) {
// Sub-features can't outlive their parents.
if (out.quotes === false) out.bills = false;
if (out.calendar === false) out.calendarBooking = false;
// Clients parent flag is DERIVED from its children. Admins don't
// toggle it directly in the Features tab — they enable a specific
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
// later) and the Clients sidebar section lights up automatically.
// Computing the value here (rather than only on writes) means GET
// /admin/feature-flags also returns a consistent state if the DB
// ever drifts (e.g. partial migration run).
out.clients = Boolean(
out.customerPortal
// future siblings (out.calendar || out.quotes || out.bills || out.messaging) go here
);
return out;
}
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const flags = await readAllFlags();
res.json(flags);
// Always run the rules so derived flags (e.g. `clients`) and
// hard invariants (galleries always on) are consistent even if
// the DB row is stale or missing.
res.json(applyDependencyRules(flags));
} catch (error) {
logger.error('Failed to read feature flags', { error: error.message });
res.status(500).json({ error: 'Failed to read feature flags' });
+22
View File
@@ -303,6 +303,16 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_mode,
hide_powered_by,
force_color_mode,
// Login-page-only branding (#354 follow-up). Both toggles apply
// exclusively to /admin/login and /customer/login — the gallery
// and admin chrome use their own logo_size / logo_max_height.
// - login_logo_frame_enabled: true (default) renders the tinted
// square behind the logo; false drops it.
// - login_logo_size: 'small' | 'medium' | 'large' | 'xlarge'
// matches the gallery logo_size token set but applies only to
// the two login screens.
login_logo_frame_enabled,
login_logo_size,
// Footer overhaul (#441 + #440). Socials are URL strings (empty
// hides the icon). Promo content is markdown (rendered via
// marked → DOMPurify on the frontend, no raw HTML accepted).
@@ -330,6 +340,13 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
? 'below_footer'
: 'above_footer';
// Normalize login_logo_size to the same token set as logo_size.
// Anything else falls back to 'medium' on the next render.
const allowedLoginLogoSizes = ['small', 'medium', 'large', 'xlarge'];
const normalizedLoginLogoSize = allowedLoginLogoSizes.includes(login_logo_size)
? login_logo_size
: undefined;
const brandingSettings = {
company_name,
company_tagline,
@@ -350,6 +367,11 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
logo_display_mode,
hide_powered_by,
force_color_mode: normalizedForceColorMode,
// Login-only knobs (only persist when the request actually
// included the key, so a partial PUT from another tab doesn't
// accidentally clear them).
...(login_logo_frame_enabled !== undefined && { login_logo_frame_enabled }),
...(normalizedLoginLogoSize !== undefined && { login_logo_size: normalizedLoginLogoSize }),
// Footer overhaul (#441 + #440). String fields normalize empty/
// undefined → '' so the column is always a known type. Only persist
// when the request actually included the key (partial PUTs).
+9 -6
View File
@@ -48,12 +48,15 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
// ---- login -------------------------------------------------------------
// The customerPortal feature flag deliberately does NOT gate this route.
// Flipping the master toggle off in Settings → Features hides the admin
// UI surface (sidebar entry, /admin/customers page) but does not revoke
// access for customers who already accepted an invitation. To lock out
// existing customers, deactivate their accounts individually
// (customer_accounts.is_active = false) — which IS enforced below.
// 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).
router.post('/login', [
body('email').isEmail().normalizeEmail().withMessage('Valid email is required'),
body('password').isString().notEmpty(),
+9
View File
@@ -85,6 +85,15 @@ router.get('/', async (req, res) => {
: settingsObject.branding_force_color_mode === 'light'
? 'light'
: null,
// Login-page-only branding (#354 follow-up). Applies exclusively
// to /admin/login and /customer/login — the rest of the app keeps
// using branding_logo_size / branding_logo_max_height. Default
// true / 'medium' preserves the visual state shipped before the
// toggles existed.
branding_login_logo_frame_enabled: settingsObject.branding_login_logo_frame_enabled !== false,
branding_login_logo_size: ['small', 'medium', 'large', 'xlarge'].includes(settingsObject.branding_login_logo_size)
? settingsObject.branding_login_logo_size
: 'medium',
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,