fix(accounting): gate cross-add counters on the permission their endpoint checks (#984)
Closes #983. The two cross-add counter queries added in #979 were enabled on customers.edit, but neither endpoint checks that permission: HoursSection -> GET /expenses/inbound/by-customer/:id needs accounting.view CustomerCrmPanels -> GET /customers/:id/hour-entries needs customers.view An admin holding customers.edit but not the corresponding read permission fired a guaranteed 403 on every customer-detail render. It degraded safely — the count stayed at its 0 default so the cross-add was never offered, which is the right outcome for that role — so this was request noise rather than broken behaviour. Each guard now requires both: the read permission to fetch the count, and the write permission because there is no point offering the cross-add to someone who cannot create the combined invoice. No seeded role is affected: migration 123 grants accounting.view and accounting.manage together, and customers.edit projects forward from customers.create, which migration 090 always grants alongside customers.view.
This commit is contained in:
@@ -252,6 +252,10 @@ const RebillsPanel: React.FC<Props> = ({ customerAccountId }) => {
|
|||||||
const canView = usePermission('accounting.view');
|
const canView = usePermission('accounting.view');
|
||||||
const canManage = usePermission('accounting.manage');
|
const canManage = usePermission('accounting.manage');
|
||||||
const canCombine = usePermission('customers.edit');
|
const canCombine = usePermission('customers.edit');
|
||||||
|
// The open-hours counter below reads GET /customers/:id/hour-entries, which
|
||||||
|
// requires customers.view — a different permission from the customers.edit
|
||||||
|
// that authorises the combined billing itself (#983).
|
||||||
|
const canViewCustomers = usePermission('customers.view');
|
||||||
const [crossAddOpen, setCrossAddOpen] = useState(false);
|
const [crossAddOpen, setCrossAddOpen] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -262,12 +266,15 @@ const RebillsPanel: React.FC<Props> = ({ customerAccountId }) => {
|
|||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open hours count for the cross-add offer — only when hours logging is on
|
// Open hours count for the cross-add offer — only when hours logging is on,
|
||||||
// AND the admin can actually create the combined invoice.
|
// the admin can actually create the combined invoice (customers.edit) AND can
|
||||||
|
// read the hour entries the count comes from (customers.view). Both are
|
||||||
|
// required: without the read permission the request just 403s on every
|
||||||
|
// render (#983).
|
||||||
const { data: openHours = 0 } = useQuery({
|
const { data: openHours = 0 } = useQuery({
|
||||||
queryKey: ['customer-open-hours-count', customerAccountId],
|
queryKey: ['customer-open-hours-count', customerAccountId],
|
||||||
queryFn: async () => (await customerAdminService.listHourEntries(customerAccountId, 'unbilled')).length,
|
queryFn: async () => (await customerAdminService.listHourEntries(customerAccountId, 'unbilled')).length,
|
||||||
enabled: !!flags.hoursLogging && canCombine,
|
enabled: !!flags.hoursLogging && canCombine && canViewCustomers,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
|||||||
const { flags } = useFeatureFlags();
|
const { flags } = useFeatureFlags();
|
||||||
// Billing hours (and the combined path) go through customers.edit (#866 review).
|
// Billing hours (and the combined path) go through customers.edit (#866 review).
|
||||||
const canBill = usePermission('customers.edit');
|
const canBill = usePermission('customers.edit');
|
||||||
|
// The open-re-bills counter below reads GET /expenses/inbound/by-customer/:id,
|
||||||
|
// which requires accounting.view — a different permission from the one that
|
||||||
|
// authorises the billing itself (#983).
|
||||||
|
const canViewAccounting = usePermission('accounting.view');
|
||||||
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
|
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
|
||||||
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
|
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||||
const [startTime, setStartTime] = useState('09:00');
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
@@ -167,11 +171,14 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Open re-bills count for the cross-add offer (#866) — only when the
|
// Open re-bills count for the cross-add offer (#866) — only when the
|
||||||
// incoming-invoices feature is on and the admin can create the invoice.
|
// incoming-invoices feature is on, the admin can create the invoice
|
||||||
|
// (customers.edit) AND can read the re-bills the count comes from
|
||||||
|
// (accounting.view). Both are required: without the read permission the
|
||||||
|
// request just 403s on every render (#983).
|
||||||
const { data: openRebills = 0 } = useQuery({
|
const { data: openRebills = 0 } = useQuery({
|
||||||
queryKey: ['customer-open-rebills-count', customerId],
|
queryKey: ['customer-open-rebills-count', customerId],
|
||||||
queryFn: async () => (await accountingService.listCustomerRebills(customerId)).filter((r) => r.status === 'open').length,
|
queryFn: async () => (await accountingService.listCustomerRebills(customerId)).filter((r) => r.status === 'open').length,
|
||||||
enabled: !!flags.incomingInvoices && canBill,
|
enabled: !!flags.incomingInvoices && canBill && canViewAccounting,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const [crossAddOpen, setCrossAddOpen] = useState(false);
|
const [crossAddOpen, setCrossAddOpen] = useState(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user