fix(flags): close CRM/accounting feature-gating gaps from the audit
A sweep of every CRM/accounting toggle found surfaces still reachable
with their flag OFF. Adds a shared requireFeatureFlag middleware (the two
existing per-file copies predate it) and closes the gaps:
- Hours logging: only createEntry checked the flag — edit/delete/bill and
the list/summary routes were permission-only. Gate all six
/hour-entries routes on the hoursLogging master so a disabled feature
can't be read, mutated, or invoiced via a direct API hit.
- Installment plans: PUT /deals/:uuid/installment-plan mutates invoices
but wasn't bills-gated; add requireFeatureFlag('bills').
- Customer invoice PDF: /invoices/:id/pdf lacked the feature_bills check
the list + quotes routes have. Also fixes the quotes-PDF gate, which
read req.customer.feature_quotes (never populated → silent no-op).
- Customer contracts: /contracts + /contracts/:id/pdf were gated by
neither the master nor a per-customer column.
Per-customer contracts override (the missing counterpart):
- Migration 131 adds customer_accounts.feature_contracts, default TRUE so
existing customers keep their Contracts tab (preserve-visuals).
- Effective resolver now contractsMaster AND feature_contracts; admin
detail page gains the toggle; service/validator/serializer wired.
Cleanups:
- Drop stale `taxReport` from the sidebar's Clients-reveal list (Tax moved
to Accounting); add the missing `projects` so it mirrors the context
derivation.
- SettingsPage tab-snap effect now depends on flags.accounting.
- Fix stale taxReport "forced off when bills off" comment (it's accounting).
This commit is contained in:
@@ -92,9 +92,12 @@ const navigation: NavItem[] = [
|
||||
// can't disagree: any sub-feature on lights up the entry, all off
|
||||
// hides it. Future siblings (e.g. `messaging`) get appended here
|
||||
// AND in the context derivation.
|
||||
// taxReport intentionally excluded — Tax moved to the Accounting section
|
||||
// and is not a Clients sub-nav item, so it must not reveal Clients (would
|
||||
// open an empty ClientsLayout). Mirrors the context's `clients` derivation.
|
||||
featureFlagsAny: [
|
||||
'customerPortal', 'crmDevelopment', 'quotes', 'bills',
|
||||
'taxReport', 'hoursLogging', 'contracts', 'calendar',
|
||||
'hoursLogging', 'contracts', 'calendar', 'projects',
|
||||
],
|
||||
},
|
||||
// Accounting section (migration 122) — inbound supplier invoices,
|
||||
|
||||
@@ -39,7 +39,7 @@ type EditableFields =
|
||||
| 'phone' | 'companyName' | 'billingEmail' | 'vatId'
|
||||
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
|
||||
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
|
||||
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging'
|
||||
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts'
|
||||
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
|
||||
|
||||
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
|
||||
@@ -137,6 +137,9 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
featureQuotes: customer.featureQuotes ?? false,
|
||||
featureBills: customer.featureBills ?? false,
|
||||
featureHoursLogging: customer.featureHoursLogging ?? false,
|
||||
// Contracts is opt-OUT (default on) — preserve the tab for customers
|
||||
// saved before the per-customer override existed.
|
||||
featureContracts: customer.featureContracts ?? true,
|
||||
hourlyRateMinor: customer.hourlyRateMinor ?? null,
|
||||
billingCadence: customer.billingCadence ?? 'per_event',
|
||||
billingCycleDay: customer.billingCycleDay ?? 1,
|
||||
@@ -145,7 +148,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
}
|
||||
}, [customer, form]);
|
||||
|
||||
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging') => {
|
||||
const toggleFeature = (key: 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging' | 'featureContracts') => {
|
||||
setForm((prev) => ({ ...prev, [key]: !prev[key] }) as any);
|
||||
};
|
||||
|
||||
@@ -570,7 +573,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
toggle inside it is OFF — an empty "Customer features" card
|
||||
with just a title + hint reads as broken. The Card reappears
|
||||
the moment any master flag is re-enabled. */}
|
||||
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging) && (
|
||||
{(flags.calendar || flags.quotes || flags.bills || flags.hoursLogging || flags.contracts) && (
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
|
||||
<ToggleLeft className="w-5 h-5" />
|
||||
@@ -609,6 +612,9 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
...(flags.hoursLogging
|
||||
? [{ key: 'featureHoursLogging' as const, labelKey: 'customers.field.featureHoursLogging', fallback: 'Hours logging', badge: 'new' as const }]
|
||||
: []),
|
||||
...(flags.contracts
|
||||
? [{ key: 'featureContracts' as const, labelKey: 'customer.nav.contracts', fallback: 'Contracts', badge: 'new' as const }]
|
||||
: []),
|
||||
] as const).map(({ key, labelKey, fallback, badge }) => {
|
||||
const enabled = !!form[key];
|
||||
return (
|
||||
|
||||
@@ -281,7 +281,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setActiveTab(visibleKeys[0]);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, activeTab]);
|
||||
}, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, activeTab]);
|
||||
|
||||
// For tabs that mount existing top-level pages OR bring their own
|
||||
// header (FeaturesTab has its own icon+title+description block), skip
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface CustomerAccountSummary {
|
||||
/** Per-customer hour logging (migration 129). When on, the customer
|
||||
* detail page renders the "Hours" section card. */
|
||||
featureHoursLogging?: boolean;
|
||||
/** Per-customer contracts override (migration 131). Defaults true —
|
||||
* existing customers keep their Contracts tab. */
|
||||
featureContracts?: boolean;
|
||||
/** Default hourly rate in minor units (e.g. CHF 150.00 = 15000).
|
||||
* null when admin hasn't set one — each entry then requires a
|
||||
* per-block override. */
|
||||
@@ -157,6 +160,7 @@ export const customerAdminService = {
|
||||
featureQuotes: 'feature_quotes',
|
||||
featureBills: 'feature_bills',
|
||||
featureHoursLogging: 'feature_hours_logging',
|
||||
featureContracts: 'feature_contracts',
|
||||
// Hour-logging default rate (migration 129).
|
||||
hourlyRateMinor: 'hourly_rate_minor',
|
||||
// CRM billing cadence (migration 102 + 128).
|
||||
|
||||
Reference in New Issue
Block a user