From adc9802361ee7607121db859d016c303e4f97f9b Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 22 Jun 2026 13:49:12 +0200 Subject: [PATCH] fix(rbac): sales/operational roles see deal alerts; quiet admin-only onboarding probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UAT findings from the Sales-role functional walkthrough: F1 — The deal-alert feed (stale interest, hot-lead-silent, EOI unsigned, signer overdue, reservation-needs-agreement, berth stalled, expense dupes) was gated on admin.view_audit_log, so salespeople got a 403 on the Alerts inbox. None of the 9 alert rules are audit/security signals — they're all operational — so re-gate the list route to interests.view (sales, director, viewer get it; external residential partners don't) and hide the Alerts section in the inbox for users without it instead of letting the query 403. F2 — Non-admins triggered /api/v1/admin/onboarding/status (admin-only) and ate a 403 in the console. Make useOnboardingStatus strictly opt-in (enabled: opts.enabled === true) so a transient/stale isSuperAdmin during permission hydration can't fire the privileged request. 1664 vitest pass; tsc + eslint clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/api/v1/alerts/route.ts | 10 ++++--- src/components/inbox/inbox-page-shell.tsx | 36 ++++++++++++++--------- src/hooks/use-onboarding-status.ts | 9 ++++-- 3 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/app/api/v1/alerts/route.ts b/src/app/api/v1/alerts/route.ts index f01c90f3..dcdccf1e 100644 --- a/src/app/api/v1/alerts/route.ts +++ b/src/app/api/v1/alerts/route.ts @@ -5,11 +5,13 @@ import { listAlertsForPort } from '@/lib/services/alerts.service'; type AlertStatus = 'open' | 'dismissed' | 'resolved'; -// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent -// signals. Gated on admin.view_audit_log - same permission the audit log -// page uses. +// The alert feed is entirely operational/deal signals (stale interest, hot lead +// silent, EOI unsigned, signer overdue, reservation needs agreement, berth +// stalled, duplicate/unscanned expense) — there are no audit/security alert +// rules. Gated on interests.view so the operational roles that act on these +// (sales, director, viewer) see them; external residential partners don't. export const GET = withAuth( - withPermission('admin', 'view_audit_log', async (req: NextRequest, ctx) => { + withPermission('interests', 'view', async (req: NextRequest, ctx) => { const url = new URL(req.url); const status = (url.searchParams.get('status') ?? 'open') as AlertStatus; diff --git a/src/components/inbox/inbox-page-shell.tsx b/src/components/inbox/inbox-page-shell.tsx index 47a94afd..e9c2c548 100644 --- a/src/components/inbox/inbox-page-shell.tsx +++ b/src/components/inbox/inbox-page-shell.tsx @@ -8,6 +8,7 @@ import { PageHeader } from '@/components/shared/page-header'; import { AlertsPageShell } from '@/components/alerts/alerts-page-shell'; import { ReminderList } from '@/components/reminders/reminder-list'; import { useAlertCount } from '@/components/alerts/use-alerts'; +import { usePermissions } from '@/hooks/use-permissions'; /** * Merged "Inbox" surface - replaces the previously-separate /alerts and @@ -29,6 +30,11 @@ export function InboxPageShell() { const [alertsOpen, setAlertsOpen] = useState(true); const [remindersOpen, setRemindersOpen] = useState(true); const { data: alertCount } = useAlertCount(); + // The deal-alert feed (stale interests, overdue signers, …) is gated on + // interests.view — operational roles see it; external residential partners + // don't. Hide the whole section rather than letting its query 403. + const { can } = usePermissions(); + const canSeeAlerts = can('interests', 'view'); // localStorage hydration on mount - canonical "read from external // store" pattern. setState in effect is intentional. @@ -95,20 +101,22 @@ export function InboxPageShell() { ) : null} -
- } - label="Alerts" - count={activeAlerts} - open={alertsOpen} - onToggle={toggleAlerts} - /> - {alertsOpen ? ( -
- -
- ) : null} -
+ {canSeeAlerts ? ( +
+ } + label="Alerts" + count={activeAlerts} + open={alertsOpen} + onToggle={toggleAlerts} + /> + {alertsOpen ? ( +
+ +
+ ) : null} +
+ ) : null} ); } diff --git a/src/hooks/use-onboarding-status.ts b/src/hooks/use-onboarding-status.ts index 82d056ce..59c31c5c 100644 --- a/src/hooks/use-onboarding-status.ts +++ b/src/hooks/use-onboarding-status.ts @@ -27,8 +27,11 @@ export interface OnboardingStatusPayload { * and the admin checklist summary. Cached for 60s so all three surfaces * share a single fetch on first paint. * - * Pass `enabled=false` to skip the network call (e.g. when the current - * user isn't a super_admin and the surface won't render anyway). + * Defaults to OFF: the endpoint is admin-only (admin.manage_settings), so + * callers must opt in with `enabled: true` once they've confirmed the user is + * a super_admin. This prevents a transient 403 (e.g. a stale `isSuperAdmin` + * during permission hydration) from firing the privileged request for + * non-admins. */ export function useOnboardingStatus(opts: { enabled?: boolean } = {}) { return useQuery({ @@ -38,7 +41,7 @@ export function useOnboardingStatus(opts: { enabled?: boolean } = {}) { (r) => r.data, ), staleTime: 60_000, - enabled: opts.enabled ?? true, + enabled: opts.enabled === true, retry: false, }); }