fix(audit): UI — L18 (decorative emoji -> Lucide icons), L19 (gated NotesList timer + create-from-url ref-in-effect)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,84 @@ export type { RolePermissions };
|
||||
export type PermissionResource = keyof RolePermissions;
|
||||
export type PermissionAction<R extends PermissionResource> = keyof RolePermissions[R];
|
||||
|
||||
/**
|
||||
* Canonical permission catalog — the SINGLE source of truth for which
|
||||
* `resource → action` leaves are valid across the app.
|
||||
*
|
||||
* Derived structurally from `RolePermissions` (the `satisfies` clause below
|
||||
* forces this literal to enumerate every resource and every action that the
|
||||
* type declares; adding a leaf to `RolePermissions` without adding it here is
|
||||
* a compile error). Both the role-creation validator
|
||||
* (`src/lib/validators/roles.ts`) and the per-user override allow-list
|
||||
* (`src/app/api/v1/admin/users/[id]/permission-overrides/route.ts`) build their
|
||||
* accepted-key sets from this object, so the two can never diverge again
|
||||
* (audit finding L23).
|
||||
*/
|
||||
export const PERMISSION_CATALOG = {
|
||||
clients: ['view', 'create', 'edit', 'delete', 'merge', 'export'],
|
||||
interests: [
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'delete',
|
||||
'change_stage',
|
||||
'override_stage',
|
||||
'generate_eoi',
|
||||
'export',
|
||||
],
|
||||
berths: ['view', 'edit', 'import', 'manage_waiting_list', 'update_prices'],
|
||||
documents: [
|
||||
'view',
|
||||
'create',
|
||||
'edit',
|
||||
'send_for_signing',
|
||||
'upload_signed',
|
||||
'delete',
|
||||
'manage_folders',
|
||||
],
|
||||
expenses: ['view', 'create', 'edit', 'delete', 'export', 'scan_receipt'],
|
||||
invoices: ['view', 'create', 'edit', 'delete', 'send', 'record_payment', 'export'],
|
||||
payments: ['view', 'record', 'delete'],
|
||||
files: ['view', 'upload', 'edit', 'delete', 'manage_folders'],
|
||||
email: ['view', 'send', 'configure_account'],
|
||||
reminders: ['view_own', 'view_all', 'create', 'edit_own', 'edit_all', 'assign_others'],
|
||||
calendar: ['connect', 'view_events'],
|
||||
reports: ['view_dashboard', 'view_analytics', 'export'],
|
||||
document_templates: ['view', 'generate', 'manage'],
|
||||
yachts: ['view', 'create', 'edit', 'delete', 'transfer'],
|
||||
companies: ['view', 'create', 'edit', 'delete'],
|
||||
memberships: ['view', 'manage'],
|
||||
tenancies: ['view', 'manage', 'cancel'],
|
||||
admin: [
|
||||
'manage_users',
|
||||
'view_audit_log',
|
||||
'manage_settings',
|
||||
'manage_webhooks',
|
||||
'manage_reports',
|
||||
'manage_custom_fields',
|
||||
'manage_forms',
|
||||
'manage_tags',
|
||||
'system_backup',
|
||||
'permanently_delete_clients',
|
||||
],
|
||||
residential_clients: ['view', 'create', 'edit', 'delete'],
|
||||
residential_interests: ['view', 'create', 'edit', 'delete', 'change_stage'],
|
||||
} as const satisfies {
|
||||
[R in PermissionResource]: ReadonlyArray<PermissionAction<R> & string>;
|
||||
};
|
||||
|
||||
/** Every valid resource key, in catalog order. */
|
||||
export const PERMISSION_RESOURCES = Object.keys(PERMISSION_CATALOG) as PermissionResource[];
|
||||
|
||||
/**
|
||||
* Same catalog as `PERMISSION_CATALOG` but with each action list materialised
|
||||
* as a `Set` for O(1) membership tests. Consumed by the per-user override
|
||||
* allow-list to drop unknown resources/actions before persisting.
|
||||
*/
|
||||
export const ALLOWED_RESOURCE_ACTIONS: Record<string, ReadonlySet<string>> = Object.fromEntries(
|
||||
Object.entries(PERMISSION_CATALOG).map(([resource, actions]) => [resource, new Set(actions)]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Checks whether a permissions map grants a specific resource/action pair.
|
||||
*
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const permissionGroupSchema = z.record(z.string(), z.boolean());
|
||||
import { PERMISSION_CATALOG } from '@/lib/auth/permissions';
|
||||
|
||||
const rolePermissionsSchema = z.object({
|
||||
clients: permissionGroupSchema,
|
||||
interests: permissionGroupSchema,
|
||||
berths: permissionGroupSchema,
|
||||
documents: permissionGroupSchema,
|
||||
expenses: permissionGroupSchema,
|
||||
invoices: permissionGroupSchema,
|
||||
payments: permissionGroupSchema,
|
||||
files: permissionGroupSchema,
|
||||
email: permissionGroupSchema,
|
||||
reminders: permissionGroupSchema,
|
||||
calendar: permissionGroupSchema,
|
||||
reports: permissionGroupSchema,
|
||||
document_templates: permissionGroupSchema,
|
||||
admin: permissionGroupSchema,
|
||||
});
|
||||
/**
|
||||
* Role-permission validation schema, built from the canonical
|
||||
* `PERMISSION_CATALOG` (`src/lib/auth/permissions.ts`) so it can never drift
|
||||
* from the per-user override allow-list again (audit finding L23).
|
||||
*
|
||||
* Previously this was `z.record(z.string(), z.boolean())` per resource, which
|
||||
* accepted ARBITRARY action keys and was missing several resources entirely
|
||||
* (`yachts`, `companies`, `memberships`, `tenancies`, `payments`,
|
||||
* `residential_*`, `document_templates`). It now:
|
||||
* - rejects unknown resources (top-level `.strict()`),
|
||||
* - rejects unknown actions within a resource (per-resource `.strict()`),
|
||||
* - accepts full OR partial action maps (each action `.optional()`), so the
|
||||
* seed defaults and the admin role form (which send full maps) and any
|
||||
* future partial-patch caller all validate.
|
||||
*/
|
||||
const rolePermissionsSchema = z
|
||||
.object(
|
||||
Object.fromEntries(
|
||||
Object.entries(PERMISSION_CATALOG).map(([resource, actions]) => [
|
||||
resource,
|
||||
z
|
||||
.object(Object.fromEntries(actions.map((action) => [action, z.boolean().optional()])))
|
||||
.strict(),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.strict();
|
||||
|
||||
export const createRoleSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
|
||||
Reference in New Issue
Block a user