2 Commits

Author SHA1 Message Date
adc9802361 fix(rbac): sales/operational roles see deal alerts; quiet admin-only onboarding probe
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m3s
Build & Push Docker Images / build-and-push (push) Successful in 8m23s
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>
2026-06-22 13:49:12 +02:00
d8f739a7c2 feat(rbac): GDPR export becomes a toggleable clients.gdpr_export permission
Previously the GDPR export trigger + download routes were gated by
admin.manage_settings, so sales roles couldn't run a client data export.
Per request, make it a dedicated, toggleable permission that's on by
default for sales-capable roles and hides the button when withheld.

- New RolePermissions leaf clients.gdpr_export (+ PERMISSION_CATALOG entry);
  strict type forces every role map + fixture to declare it.
- Granted true for super_admin / director / sales_manager / sales_agent;
  false for viewer / residential_partner.
- GDPR export POST (trigger) and [exportId] GET (download) re-gated from
  admin.manage_settings -> clients.gdpr_export.
- GdprExportButton visibility now keys off clients.gdpr_export, so toggling
  it off per-user hides the function entirely.
- Migration 0098 backfills the key onto existing role rows (idempotent).

Verified end-to-end as a Sales user: trigger (202) -> worker build (ready)
-> list (200) -> download (200). 1664 vitest pass; tsc + eslint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 13:20:31 +02:00
12 changed files with 155 additions and 36 deletions

View File

@@ -5,11 +5,13 @@ import { listAlertsForPort } from '@/lib/services/alerts.service';
type AlertStatus = 'open' | 'dismissed' | 'resolved'; type AlertStatus = 'open' | 'dismissed' | 'resolved';
// Tier-4 (authz-auditor): alerts include permission_denied + audit-adjacent // The alert feed is entirely operational/deal signals (stale interest, hot lead
// signals. Gated on admin.view_audit_log - same permission the audit log // silent, EOI unsigned, signer overdue, reservation needs agreement, berth
// page uses. // 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( 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 url = new URL(req.url);
const status = (url.searchParams.get('status') ?? 'open') as AlertStatus; const status = (url.searchParams.get('status') ?? 'open') as AlertStatus;

View File

@@ -16,8 +16,8 @@ import { createAuditLog } from '@/lib/audit';
*/ */
export const GET = withAuth( export const GET = withAuth(
withPermission( withPermission(
'admin', 'clients',
'manage_settings', 'gdpr_export',
withRateLimit('exports', async (req, ctx, params) => { withRateLimit('exports', async (req, ctx, params) => {
try { try {
const url = await getExportDownloadUrl(params.exportId!, ctx.portId); const url = await getExportDownloadUrl(params.exportId!, ctx.portId);

View File

@@ -26,8 +26,8 @@ export const GET = withAuth(
export const POST = withAuth( export const POST = withAuth(
withPermission( withPermission(
'admin', 'clients',
'manage_settings', 'gdpr_export',
withRateLimit('exports', async (req, ctx, params) => { withRateLimit('exports', async (req, ctx, params) => {
try { try {
const body = await parseBody(req, requestSchema); const body = await parseBody(req, requestSchema);

View File

@@ -63,7 +63,7 @@ export function GdprExportButton({
const [emailToClient, setEmailToClient] = useState(false); const [emailToClient, setEmailToClient] = useState(false);
const [emailOverride, setEmailOverride] = useState(''); const [emailOverride, setEmailOverride] = useState('');
const allowed = isSuperAdmin || can('admin', 'manage_settings'); const allowed = isSuperAdmin || can('clients', 'gdpr_export');
const queryKey = ['gdpr-exports', clientId]; const queryKey = ['gdpr-exports', clientId];
const { data, isLoading } = useQuery<ListResp>({ const { data, isLoading } = useQuery<ListResp>({

View File

@@ -8,6 +8,7 @@ import { PageHeader } from '@/components/shared/page-header';
import { AlertsPageShell } from '@/components/alerts/alerts-page-shell'; import { AlertsPageShell } from '@/components/alerts/alerts-page-shell';
import { ReminderList } from '@/components/reminders/reminder-list'; import { ReminderList } from '@/components/reminders/reminder-list';
import { useAlertCount } from '@/components/alerts/use-alerts'; import { useAlertCount } from '@/components/alerts/use-alerts';
import { usePermissions } from '@/hooks/use-permissions';
/** /**
* Merged "Inbox" surface - replaces the previously-separate /alerts and * Merged "Inbox" surface - replaces the previously-separate /alerts and
@@ -29,6 +30,11 @@ export function InboxPageShell() {
const [alertsOpen, setAlertsOpen] = useState(true); const [alertsOpen, setAlertsOpen] = useState(true);
const [remindersOpen, setRemindersOpen] = useState(true); const [remindersOpen, setRemindersOpen] = useState(true);
const { data: alertCount } = useAlertCount(); 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 // localStorage hydration on mount - canonical "read from external
// store" pattern. setState in effect is intentional. // store" pattern. setState in effect is intentional.
@@ -95,20 +101,22 @@ export function InboxPageShell() {
) : null} ) : null}
</section> </section>
<section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs"> {canSeeAlerts ? (
<SectionHeader <section id="inbox-section-alerts" className="rounded-lg border bg-card shadow-xs">
icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />} <SectionHeader
label="Alerts" icon={<ShieldAlert className="size-4 text-muted-foreground" aria-hidden />}
count={activeAlerts} label="Alerts"
open={alertsOpen} count={activeAlerts}
onToggle={toggleAlerts} open={alertsOpen}
/> onToggle={toggleAlerts}
{alertsOpen ? ( />
<div className="border-t px-4 pb-4 pt-3"> {alertsOpen ? (
<AlertsPageShell embedded /> <div className="border-t px-4 pb-4 pt-3">
</div> <AlertsPageShell embedded />
) : null} </div>
</section> ) : null}
</section>
) : null}
</div> </div>
); );
} }

View File

@@ -27,8 +27,11 @@ export interface OnboardingStatusPayload {
* and the admin checklist summary. Cached for 60s so all three surfaces * and the admin checklist summary. Cached for 60s so all three surfaces
* share a single fetch on first paint. * share a single fetch on first paint.
* *
* Pass `enabled=false` to skip the network call (e.g. when the current * Defaults to OFF: the endpoint is admin-only (admin.manage_settings), so
* user isn't a super_admin and the surface won't render anyway). * 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 } = {}) { export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
return useQuery<OnboardingStatusPayload>({ return useQuery<OnboardingStatusPayload>({
@@ -38,7 +41,7 @@ export function useOnboardingStatus(opts: { enabled?: boolean } = {}) {
(r) => r.data, (r) => r.data,
), ),
staleTime: 60_000, staleTime: 60_000,
enabled: opts.enabled ?? true, enabled: opts.enabled === true,
retry: false, retry: false,
}); });
} }

View File

@@ -21,7 +21,7 @@ export type PermissionAction<R extends PermissionResource> = keyof RolePermissio
* (audit finding L23). * (audit finding L23).
*/ */
export const PERMISSION_CATALOG = { export const PERMISSION_CATALOG = {
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'], clients: ['view', 'create', 'edit', 'delete', 'merge', 'export', 'gdpr_export'],
interests: [ interests: [
'view', 'view',
'create', 'create',

View File

@@ -0,0 +1,23 @@
-- New toggleable permission: clients.gdpr_export (trigger + download a client's
-- GDPR data export). Previously the export routes were gated by
-- admin.manage_settings, which sales roles lack. This grants it to the
-- sales-capable system roles by default and makes it an explicit (off) toggle
-- everywhere else, so admins can withhold it per-user (which hides the button).
--
-- Existing role rows store permissions as jsonb, so editing the seed/role maps
-- alone won't reach them — this backfills the key. Idempotent.
-- Sales-capable system roles get it ON by default.
UPDATE roles
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'true'::jsonb, true),
updated_at = now()
WHERE name IN ('super_admin', 'director', 'sales_manager', 'sales_agent')
AND permissions ? 'clients';
-- Every other role that has a clients block but not the key yet defaults to OFF,
-- so the permission surfaces as an explicit toggle in the matrix.
UPDATE roles
SET permissions = jsonb_set(permissions, '{clients,gdpr_export}', 'false'::jsonb, true),
updated_at = now()
WHERE permissions ? 'clients'
AND NOT (permissions -> 'clients' ? 'gdpr_export');

View File

@@ -11,6 +11,9 @@ export type RolePermissions = {
delete: boolean; delete: boolean;
merge: boolean; merge: boolean;
export: boolean; export: boolean;
/** Trigger + download a GDPR data export for a client. Toggleable so it
* can be hidden from a user (e.g. a sales rep) when withheld. */
gdpr_export: boolean;
}; };
interests: { interests: {
view: boolean; view: boolean;

View File

@@ -12,7 +12,15 @@
import type { RolePermissions } from './schema/users'; import type { RolePermissions } from './schema/users';
export const ALL_PERMISSIONS: RolePermissions = { export const ALL_PERMISSIONS: RolePermissions = {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: true,
merge: true,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -104,7 +112,15 @@ export const ALL_PERMISSIONS: RolePermissions = {
// reference the sales map directly. // reference the sales map directly.
export const SALES_MANAGER_PERMISSIONS: RolePermissions = { export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
clients: { view: true, create: true, edit: true, delete: false, merge: true, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: false,
merge: true,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -196,7 +212,15 @@ export const SALES_MANAGER_PERMISSIONS: RolePermissions = {
export const DIRECTOR_PERMISSIONS: RolePermissions = SALES_MANAGER_PERMISSIONS; export const DIRECTOR_PERMISSIONS: RolePermissions = SALES_MANAGER_PERMISSIONS;
export const SALES_AGENT_PERMISSIONS: RolePermissions = { export const SALES_AGENT_PERMISSIONS: RolePermissions = {
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: false,
merge: false,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -283,7 +307,15 @@ export const SALES_AGENT_PERMISSIONS: RolePermissions = {
}; };
export const VIEWER_PERMISSIONS: RolePermissions = { export const VIEWER_PERMISSIONS: RolePermissions = {
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, clients: {
view: true,
create: false,
edit: false,
delete: false,
merge: false,
export: false,
gdpr_export: false,
},
interests: { interests: {
view: true, view: true,
create: false, create: false,
@@ -379,7 +411,15 @@ export const VIEWER_PERMISSIONS: RolePermissions = {
// inquiries on the marina's behalf. Sees only the residential pages and // inquiries on the marina's behalf. Sees only the residential pages and
// nothing else; can't see marina clients, yachts, berths, EOIs, etc. // nothing else; can't see marina clients, yachts, berths, EOIs, etc.
export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = { export const RESIDENTIAL_PARTNER_PERMISSIONS: RolePermissions = {
clients: { view: false, create: false, edit: false, delete: false, merge: false, export: false }, clients: {
view: false,
create: false,
edit: false,
delete: false,
merge: false,
export: false,
gdpr_export: false,
},
interests: { interests: {
view: false, view: false,
create: false, create: false,

View File

@@ -302,7 +302,15 @@ import type { RolePermissions } from '@/lib/db/schema/users';
/** Full permissions - every action allowed. */ /** Full permissions - every action allowed. */
export function makeFullPermissions(): RolePermissions { export function makeFullPermissions(): RolePermissions {
return { return {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: true,
merge: true,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -392,7 +400,15 @@ export function makeFullPermissions(): RolePermissions {
/** Read-only viewer permissions - no create/update/delete. */ /** Read-only viewer permissions - no create/update/delete. */
export function makeViewerPermissions(): RolePermissions { export function makeViewerPermissions(): RolePermissions {
return { return {
clients: { view: true, create: false, edit: false, delete: false, merge: false, export: false }, clients: {
view: true,
create: false,
edit: false,
delete: false,
merge: false,
export: false,
gdpr_export: false,
},
interests: { interests: {
view: true, view: true,
create: false, create: false,
@@ -482,7 +498,15 @@ export function makeViewerPermissions(): RolePermissions {
/** Sales agent permissions - own clients/interests, no admin. */ /** Sales agent permissions - own clients/interests, no admin. */
export function makeSalesAgentPermissions(): RolePermissions { export function makeSalesAgentPermissions(): RolePermissions {
return { return {
clients: { view: true, create: true, edit: true, delete: false, merge: false, export: false }, clients: {
view: true,
create: true,
edit: true,
delete: false,
merge: false,
export: false,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,
@@ -572,7 +596,15 @@ export function makeSalesAgentPermissions(): RolePermissions {
/** Sales manager - can do most things, limited admin. */ /** Sales manager - can do most things, limited admin. */
export function makeSalesManagerPermissions(): RolePermissions { export function makeSalesManagerPermissions(): RolePermissions {
return { return {
clients: { view: true, create: true, edit: true, delete: true, merge: true, export: true }, clients: {
view: true,
create: true,
edit: true,
delete: true,
merge: true,
export: true,
gdpr_export: true,
},
interests: { interests: {
view: true, view: true,
create: true, create: true,

View File

@@ -99,6 +99,10 @@ describe('Permission Matrix - viewer', () => {
expect(await checkPermission(ctx, 'clients', 'create')).toBe(403); expect(await checkPermission(ctx, 'clients', 'create')).toBe(403);
}); });
it('cannot run a GDPR export', async () => {
expect(await checkPermission(ctx, 'clients', 'gdpr_export')).toBe(403);
});
it('cannot update clients', async () => { it('cannot update clients', async () => {
expect(await checkPermission(ctx, 'clients', 'edit')).toBe(403); expect(await checkPermission(ctx, 'clients', 'edit')).toBe(403);
}); });
@@ -177,6 +181,10 @@ describe('Permission Matrix - sales_manager', () => {
} }
}); });
it('can run a GDPR export (clients.gdpr_export)', async () => {
expect(await checkPermission(ctx, 'clients', 'gdpr_export')).toBe(200);
});
it('can view audit log', async () => { it('can view audit log', async () => {
expect(await checkPermission(ctx, 'admin', 'view_audit_log')).toBe(200); expect(await checkPermission(ctx, 'admin', 'view_audit_log')).toBe(200);
}); });