fix(rbac): sales/operational roles see deal alerts; quiet admin-only onboarding probe
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
</section>
|
||||
|
||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||
<SectionHeader
|
||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||
label="Alerts"
|
||||
count={activeAlerts}
|
||||
open={alertsOpen}
|
||||
onToggle={toggleAlerts}
|
||||
/>
|
||||
{alertsOpen ? (
|
||||
<div className="border-t px-4 pb-4 pt-3">
|
||||
<AlertsPageShell embedded />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
{canSeeAlerts ? (
|
||||
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
|
||||
<SectionHeader
|
||||
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
|
||||
label="Alerts"
|
||||
count={activeAlerts}
|
||||
open={alertsOpen}
|
||||
onToggle={toggleAlerts}
|
||||
/>
|
||||
{alertsOpen ? (
|
||||
<div className="border-t px-4 pb-4 pt-3">
|
||||
<AlertsPageShell embedded />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<OnboardingStatusPayload>({
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user