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', () => {