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:
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user