diff --git a/src/app/api/v1/clients/[id]/archive-dossier/route.ts b/src/app/api/v1/clients/[id]/archive-dossier/route.ts index e52a1cd8..1060435e 100644 --- a/src/app/api/v1/clients/[id]/archive-dossier/route.ts +++ b/src/app/api/v1/clients/[id]/archive-dossier/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; +import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions'; import { getClientArchiveDossier } from '@/lib/services/client-archive-dossier.service'; import { errorResponse, NotFoundError } from '@/lib/errors'; @@ -10,7 +11,7 @@ import { errorResponse, NotFoundError } from '@/lib/errors'; * decision points, and warnings. */ export const GET = withAuth( - withPermission('clients', 'delete', async (_req, ctx, params) => { + withPermission('clients', CLIENT_ARCHIVE_ACTION, async (_req, ctx, params) => { try { const id = params.id; if (!id) throw new NotFoundError('client'); diff --git a/src/app/api/v1/clients/[id]/archive/route.ts b/src/app/api/v1/clients/[id]/archive/route.ts index b5cd36df..71e353ad 100644 --- a/src/app/api/v1/clients/[id]/archive/route.ts +++ b/src/app/api/v1/clients/[id]/archive/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; +import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions'; import { parseBody } from '@/lib/api/route-helpers'; import { archiveClientWithDecisions, @@ -63,7 +64,7 @@ const decisionsSchema = z.object({ }); export const POST = withAuth( - withPermission('clients', 'delete', async (req, ctx, params) => { + withPermission('clients', CLIENT_ARCHIVE_ACTION, async (req, ctx, params) => { try { const id = params.id; if (!id) throw new NotFoundError('client'); diff --git a/src/app/api/v1/clients/[id]/route.ts b/src/app/api/v1/clients/[id]/route.ts index 7d80ec7b..2cb39106 100644 --- a/src/app/api/v1/clients/[id]/route.ts +++ b/src/app/api/v1/clients/[id]/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; +import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions'; import { parseBody } from '@/lib/api/route-helpers'; import { errorResponse } from '@/lib/errors'; import { getClientById, updateClient, archiveClient } from '@/lib/services/clients.service'; @@ -35,7 +36,7 @@ export const PATCH = withAuth( ); export const DELETE = withAuth( - withPermission('clients', 'delete', async (req, ctx, params) => { + withPermission('clients', CLIENT_ARCHIVE_ACTION, async (req, ctx, params) => { try { await archiveClient(params.id!, ctx.portId, { userId: ctx.userId, diff --git a/src/app/api/v1/clients/bulk-archive-preflight/route.ts b/src/app/api/v1/clients/bulk-archive-preflight/route.ts index 1e3da7b2..c69b1657 100644 --- a/src/app/api/v1/clients/bulk-archive-preflight/route.ts +++ b/src/app/api/v1/clients/bulk-archive-preflight/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { z } from 'zod'; import { withAuth, withPermission } from '@/lib/api/helpers'; +import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions'; import { parseBody } from '@/lib/api/route-helpers'; import { getClientArchiveDossier } from '@/lib/services/client-archive-dossier.service'; import { errorResponse } from '@/lib/errors'; @@ -27,7 +28,7 @@ interface PreflightItem { * - surface blockers (e.g. "has unpaid invoices") for the operator */ export const POST = withAuth( - withPermission('clients', 'delete', async (req, ctx) => { + withPermission('clients', CLIENT_ARCHIVE_ACTION, async (req, ctx) => { try { const { ids } = await parseBody(req, bodySchema); const items: PreflightItem[] = []; diff --git a/src/app/api/v1/clients/bulk/route.ts b/src/app/api/v1/clients/bulk/route.ts index 0092f761..7cda73f0 100644 --- a/src/app/api/v1/clients/bulk/route.ts +++ b/src/app/api/v1/clients/bulk/route.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { eq, and } from 'drizzle-orm'; import { withAuth, withRateLimit } from '@/lib/api/helpers'; +import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions'; import { parseBody } from '@/lib/api/route-helpers'; import { runBulk } from '@/lib/api/bulk-helpers'; import { db } from '@/lib/db'; @@ -43,7 +44,9 @@ const bulkSchema = z.discriminatedUnion('action', [ ]); const PERMISSION_BY_ACTION = { - archive: 'delete' as const, + // Archiving is reversible -> edit-level (see CLIENT_ARCHIVE_ACTION). Not the + // destructive `delete`, which only super_admin holds. + archive: CLIENT_ARCHIVE_ACTION, add_tag: 'edit' as const, remove_tag: 'edit' as const, }; diff --git a/src/components/clients/client-card.tsx b/src/components/clients/client-card.tsx index be6fd598..767f3977 100644 --- a/src/components/clients/client-card.tsx +++ b/src/components/clients/client-card.tsx @@ -27,9 +27,17 @@ interface ClientCardProps { portSlug: string; onEdit: (client: ClientRow) => void; onArchive: (client: ClientRow) => void; + /** Hide the Archive action for users who can't archive (clients:edit). */ + canArchive?: boolean; } -export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardProps) { +export function ClientCard({ + client, + portSlug, + onEdit, + onArchive, + canArchive = true, +}: ClientCardProps) { // Card display: prefer email, fall back to phone. const primaryContactValue = client.primaryEmail ?? client.primaryPhone ?? null; const nationality = client.nationalityIso ? getCountryName(client.nationalityIso, 'en') : null; @@ -96,10 +104,12 @@ export function ClientCard({ client, portSlug, onEdit, onArchive }: ClientCardPr Edit - onArchive(client)}> - - Archive - + {canArchive && ( + onArchive(client)}> + + Archive + + )} } diff --git a/src/components/clients/client-columns.tsx b/src/components/clients/client-columns.tsx index aa935404..e657a06c 100644 --- a/src/components/clients/client-columns.tsx +++ b/src/components/clients/client-columns.tsx @@ -87,12 +87,15 @@ interface GetColumnsOptions { portSlug: string; onEdit: (client: ClientRow) => void; onArchive: (client: ClientRow) => void; + /** Hide the row Archive action for users who can't archive (clients:edit). */ + canArchive?: boolean; } export function getClientColumns({ portSlug, onEdit, onArchive, + canArchive = true, }: GetColumnsOptions): ColumnDef[] { return [ { @@ -318,10 +321,15 @@ export function getClientColumns({ Edit - onArchive(row.original)}> - - Archive - + {canArchive && ( + onArchive(row.original)} + > + + Archive + + )} ), diff --git a/src/components/clients/client-detail-header.tsx b/src/components/clients/client-detail-header.tsx index c160a8cf..6fabd92e 100644 --- a/src/components/clients/client-detail-header.tsx +++ b/src/components/clients/client-detail-header.tsx @@ -146,23 +146,28 @@ export function ClientDetailHeader({ client }: ClientDetailHeaderProps) { > - + {/* Archive + Restore are reversible -> gate on clients:edit, the + same action the archive routes now require. Hidden for view-only + users so they never click a button that 403s. */} + + + diff --git a/src/components/clients/client-list.tsx b/src/components/clients/client-list.tsx index 82ef2ae5..9c93f535 100644 --- a/src/components/clients/client-list.tsx +++ b/src/components/clients/client-list.tsx @@ -101,7 +101,9 @@ export function ClientList() { const { can } = usePermissions(); const canHardDelete = can('admin', 'permanently_delete_clients'); - const canBulkArchive = can('clients', 'delete'); + // Archiving is reversible -> edit-level (see CLIENT_ARCHIVE_ACTION), not delete. + const canArchive = can('clients', 'edit'); + const canBulkArchive = canArchive; const canBulkTag = can('clients', 'edit'); const { @@ -167,6 +169,7 @@ export function ClientList() { portSlug, onEdit: (client) => setEditClient(client), onArchive: (client) => setArchiveClient(client), + canArchive, }); // Per-user column visibility, persisted into user_profiles.preferences @@ -296,6 +299,7 @@ export function ClientList() { portSlug={portSlug} onEdit={setEditClient} onArchive={setArchiveClient} + canArchive={canArchive} /> )} emptyState={ diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index e39dc97e..00fc91e0 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -75,6 +75,20 @@ export const PERMISSION_CATALOG = { [R in PermissionResource]: ReadonlyArray & string>; }; +/** + * Permission action that gates ARCHIVING a client. Archiving is reversible + * (sets `archivedAt`; restorable via the Restore flow), so it is an edit-level + * action — deliberately NOT the destructive `clients:delete` (which historically + * only super_admin held, locking every Sales/Director user out of archiving), + * and NOT `admin:permanently_delete_clients` (irreversible hard delete). + * + * Single source of truth: every client-archive route (single DELETE, archive + * with-decisions, archive-dossier, bulk-archive-preflight, bulk POST) gates on + * this, and the UI affordances gate on `clients:edit` to match. See the + * permission-matrix archive-policy test. + */ +export const CLIENT_ARCHIVE_ACTION = 'edit' as const; + /** Every valid resource key, in catalog order. */ export const PERMISSION_RESOURCES = Object.keys(PERMISSION_CATALOG) as PermissionResource[]; diff --git a/tests/integration/permission-matrix.test.ts b/tests/integration/permission-matrix.test.ts index 3d534a62..8eae8876 100644 --- a/tests/integration/permission-matrix.test.ts +++ b/tests/integration/permission-matrix.test.ts @@ -15,6 +15,7 @@ import { describe, it, expect, vi } from 'vitest'; import { withPermission, deepMerge, type AuthContext } from '@/lib/api/helpers'; +import { CLIENT_ARCHIVE_ACTION } from '@/lib/auth/permissions'; import { makeViewerPermissions, makeSalesAgentPermissions, @@ -64,6 +65,43 @@ async function checkPermission( return response.status; } +// ─── client archive policy ───────────────────────────────────────────────────── + +/** + * Archiving a client is REVERSIBLE (sets archivedAt; restorable), so it is an + * edit-level action — NOT the destructive `clients:delete` (which only + * super_admin holds) and NOT `admin:permanently_delete_clients` (hard delete). + * Regression guard: prod showed Sales Managers/Directors getting 403 on archive + * because every archive route gated on `delete`. All archive routes now gate on + * CLIENT_ARCHIVE_ACTION; if anyone flips it back to a permission the sales roles + * lack, these fail. + */ +describe('Permission Matrix - client archive policy', () => { + it('archiving is an edit-level action, not delete', () => { + expect(CLIENT_ARCHIVE_ACTION).toBe('edit'); + }); + + it('a Sales Manager can archive a client', async () => { + const ctx = makeCtx({ permissions: makeSalesManagerPermissions() }); + expect(await checkPermission(ctx, 'clients', CLIENT_ARCHIVE_ACTION)).toBe(200); + }); + + it('a Director can archive a client', async () => { + const ctx = makeCtx({ permissions: makeDirectorPermissions() }); + expect(await checkPermission(ctx, 'clients', CLIENT_ARCHIVE_ACTION)).toBe(200); + }); + + it('a Sales Agent can archive a client', async () => { + const ctx = makeCtx({ permissions: makeSalesAgentPermissions() }); + expect(await checkPermission(ctx, 'clients', CLIENT_ARCHIVE_ACTION)).toBe(200); + }); + + it('a Viewer cannot archive a client', async () => { + const ctx = makeCtx({ permissions: makeViewerPermissions() }); + expect(await checkPermission(ctx, 'clients', CLIENT_ARCHIVE_ACTION)).toBe(403); + }); +}); + // ─── super_admin ────────────────────────────────────────────────────────────── describe('Permission Matrix - super_admin', () => {