Compare commits
2 Commits
93989b1e1d
...
adc9802361
| Author | SHA1 | Date | |
|---|---|---|---|
| adc9802361 | |||
| d8f739a7c2 |
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>({
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user