# Custom Roles Settings — Implementation Plan

**Spec:** docs/specs/2026-06-01-settings-roles.md  ·  **Slug:** settings-roles  ·  **Wave:** 11
**Depends on:** foundation-auth-rbac, team-users-settings

## Goal
Deliver the `/settings/roles` page and `/api/roles` CRUD API that let tenant admins create, edit, delete, and assign permissions to custom roles. System roles (`OWNER`, `ADMIN`, `MEMBER`, `VIEWER`, `CONTRACTOR`) are shown read-only; custom roles are built from the locked permission vocabulary. Deleting a role that has members requires atomically reassigning those members to a replacement role.

## Architecture
This spec is **API + UI over the existing locked schema** — it introduces **no new table**.

The spec's literal `Schema Delta` (a new `tenant_roles` table with `permissions TEXT[]`, and `tenant_memberships.role` as TEXT) is **superseded by the canonical upstream model**. Reason: `tenant_memberships` is upstream-locked as `(user_id, tenant_id, role_id, status, freeze_reason, created_at)` with `role_id UUID REFERENCES roles(id)`, and the session/permission expansion path is `tenant_memberships.role_id → roles → role_permissions → permissions`. A `TEXT[]` side table would be invisible to the auth path and the feature would be dead end-to-end. Therefore a custom role IS a `roles` row with `is_system_role = false`, and its permissions live in `role_permissions` joined to `permissions`.

Upstream tables consumed (all locked, do NOT redefine):
- `roles (id, tenant_id, name, is_system_role, created_at)` — `UNIQUE(tenant_id, name)`. Custom role = `is_system_role = false`.
- `permissions (id, key, description)` — seed data, `UNIQUE(key)`. The permission vocabulary.
- `role_permissions (role_id, permission_id)` — `PRIMARY KEY (role_id, permission_id)`. The join set defining a role's permissions.
- `tenant_memberships (user_id, tenant_id, role_id, status, freeze_reason, created_at)` — `role_id REFERENCES roles(id)`. Used for `member_count` and reassignment.

Upstream exports consumed: `requirePermission` (Hono middleware, `packages/auth`), `authMiddleware`, `bumpUserVersion(userId)` (KV token-version bump on role change), `tenantQuery`/`systemQuery` (tenant-scoped Drizzle helpers, `packages/db`), `RoleId`/`UserId`/`TenantId` types. The frontend reuses `GET /api/roles` shape compatible with team-users-settings (which expects `{ id, name, is_system_role }` — this endpoint is a superset adding `permissions` and `member_count`).

Data flow: React page (`apps/zync-app`) → React Query hooks → Hono routes (`apps/zync-api`) guarded by `requirePermission('users:manage')` → Drizzle services (`packages/db`) that mutate `roles` + `role_permissions` (+ `tenant_memberships` on delete-reassign) inside transactions, all scoped to `tenant_id` from session.

## Tech Stack
- **API:** `apps/zync-api` (Hono on Cloudflare Workers), Zod validation, Drizzle ORM over Neon Postgres via Hyperdrive binding `DB`.
- **DB services:** `packages/db` (Drizzle schema already defines `roles`, `permissions`, `role_permissions`, `tenant_memberships`).
- **Auth:** `packages/auth` — `requirePermission`, `bumpUserVersion`.
- **App UI:** `apps/zync-app` (Vite + React), TanStack Query, `@zync/ui` primitives (`Dialog`, `Sheet`, `Button`, `Checkbox`, `Input`, `Card`, `Badge`, `Select`, `EmptyState`, `toast`, `Form`, `FormField`, `FormLabel`, `FormError`).
- **i18n/RTL:** `@zync/config` `useDirection`, `LocaleProvider`, translations; Hebrew + RTL mirror.
- **Bindings:** `DB` (Hyperdrive), `RATELIMIT_KV`/KV for token-version bumps.

## Wave Plan
| Sub-wave | Tasks | Files touched | Parallelizable? |
|----------|-------|---------------|-----------------|
| A — Vocabulary & validation | 1 | `packages/auth/src/permissions.ts`, `packages/types` | No (foundation for all) |
| B — DB services | 2 | `packages/db/src/services/roles.ts` | After A |
| C — API routes | 3 | `apps/zync-api/src/routes/roles.ts`, router index | After B |
| D — Frontend hooks + page + modal | 4, 5, 6 | `apps/zync-app` roles feature | After C (4 before 5,6); 5 & 6 parallel |
| E — i18n / a11y / tests | 7, 8 | locale files, test files | After D |

## Tasks

### Task 1: Permission vocabulary constant & Zod validation
**Blocks:** 2, 3  ·  **Blocked by:** —
**Files:**
- Create: `packages/auth/src/permissions.ts`
- Modify: `packages/auth/src/index.ts` (export)
- Modify: `packages/types/src/index.ts` (export `PermissionKey` type)
**Steps:**
- [ ] Define `PERMISSION_KEYS` as a `readonly` tuple containing every permission key from `foundation-auth-rbac`, grouped/ordered to mirror the UI modal sections.
- [ ] Define `PERMISSION_GROUPS` — an ordered array of `{ label: string; keys: PermissionKey[] }` mirroring the modal section order (Invoices, Billing, Customers, Tasks, Projects, Expenses, Time, Marketing, Reports, KB, Tickets, Calendar, Settings & Users) so the UI and API share one source of truth.
- [ ] Export `isValidPermissionKey(k: string): boolean` (membership test against the set).
- [ ] Export type `PermissionKey = typeof PERMISSION_KEYS[number]`.
- [ ] Export a Zod schema `permissionsArraySchema = z.array(z.string()).refine(arr => arr.every(isValidPermissionKey))` and the names: `z.string().min(1).max(64)` for role name (`roleNameSchema`).
**Schema / Interfaces:**
```ts
// packages/auth/src/permissions.ts
export const PERMISSION_KEYS = [
  // Tasks
  'tasks:read', 'tasks:write', 'tasks:delete', 'tasks:assign',
  // Projects
  'projects:read', 'projects:write', 'projects:delete',
  // Customers
  'customers:read', 'customers:write', 'customers:delete',
  // Invoices
  'invoices:read', 'invoices:write', 'invoices:delete', 'invoices:send',
  // Expenses
  'expenses:read', 'expenses:write',
  // Billing
  'billing:read', 'billing:manage',
  // Time tracking
  'time:read', 'time:track', 'time:manage',
  // Support / CRM
  'tickets:read', 'tickets:write', 'tickets:assign', 'tickets:resolve',
  // Knowledge Base
  'kb:read', 'kb:write', 'kb:delete', 'kb:share',
  // Marketing
  'marketing:read', 'marketing:write',
  // Reports & Analytics
  'reports:read', 'reports:export',
  // Settings
  'settings:read', 'settings:write',
  // Users
  'users:read', 'users:invite', 'users:manage', 'users:freeze',
  // Payouts
  'payouts:read', 'payouts:manage',
  // Calendar
  'calendar:read', 'calendar:write',
  // Webhooks / API
  'webhooks:read', 'webhooks:manage',
] as const;

export type PermissionKey = typeof PERMISSION_KEYS[number];

export const PERMISSION_GROUPS: ReadonlyArray<{ label: string; keys: PermissionKey[] }> = [
  { label: 'Invoices',  keys: ['invoices:read', 'invoices:write', 'invoices:delete', 'invoices:send'] },
  { label: 'Billing',   keys: ['billing:read', 'billing:manage'] },
  { label: 'Customers', keys: ['customers:read', 'customers:write', 'customers:delete'] },
  { label: 'Tasks',     keys: ['tasks:read', 'tasks:write', 'tasks:assign', 'tasks:delete'] },
  { label: 'Projects',  keys: ['projects:read', 'projects:write', 'projects:delete'] },
  { label: 'Expenses',  keys: ['expenses:read', 'expenses:write'] },
  { label: 'Time',      keys: ['time:read', 'time:track', 'time:manage'] },
  { label: 'Marketing', keys: ['marketing:read', 'marketing:write'] },
  { label: 'Reports',   keys: ['reports:read', 'reports:export'] },
  { label: 'KB',        keys: ['kb:read', 'kb:write', 'kb:delete', 'kb:share'] },
  { label: 'Tickets',   keys: ['tickets:read', 'tickets:write', 'tickets:assign', 'tickets:resolve'] },
  { label: 'Calendar',  keys: ['calendar:read', 'calendar:write'] },
  { label: 'Settings & Users', keys: ['users:read', 'users:invite', 'users:manage', 'users:freeze', 'settings:read', 'settings:write', 'webhooks:read', 'webhooks:manage', 'payouts:read', 'payouts:manage'] },
];

const KEY_SET = new Set<string>(PERMISSION_KEYS);
export function isValidPermissionKey(k: string): boolean { return KEY_SET.has(k); }
```
**Acceptance:**
- [ ] `PERMISSION_KEYS` contains exactly the 44 keys from the auth-rbac vocabulary; `PERMISSION_GROUPS` covers every key with no duplicates and no key outside the vocabulary.
- [ ] `isValidPermissionKey('invoices:read')` is true; `isValidPermissionKey('foo:bar')` is false.

### Task 2: Custom-role DB service layer
**Blocks:** 3  ·  **Blocked by:** 1
**Files:**
- Create: `packages/db/src/services/roles.ts`
- Modify: `packages/db/src/index.ts` (export service functions)
**Steps:**
- [ ] Implement `listCustomRoles(db, tenantId)` returning each `roles` row where `tenant_id = tenantId`, with `permissions: string[]` (LEFT JOIN `role_permissions` → `permissions.key`, aggregated) and `member_count` (`COUNT` of `tenant_memberships WHERE role_id = roles.id`). Order custom roles (`is_system_role = false`) and include `is_system_role` so callers can split read-only vs editable.
- [ ] Implement `createCustomRole(db, tenantId, name, permissionKeys)`: in a transaction, insert a `roles` row (`is_system_role = false`); resolve `permissionKeys` to `permissions.id` via `key IN (...)`; bulk-insert `role_permissions`. Return the created role serialized like `listCustomRoles`. Reject (throw typed `RoleNameConflictError`) on `UNIQUE(tenant_id, name)` violation.
- [ ] Implement `updateCustomRole(db, tenantId, roleId, { name?, permissionKeys? })`: in a transaction, verify the row exists, belongs to `tenantId`, and `is_system_role = false` (throw `SystemRoleImmutableError` otherwise). Update `name` if provided. If `permissionKeys` provided, delete-then-insert the `role_permissions` join set. Return serialized role.
- [ ] Implement `deleteCustomRole(db, tenantId, roleId, reassignToRoleId?)`: in a transaction — (1) load target role, assert `is_system_role = false` and `tenant_id = tenantId`; (2) compute `member_count`; (3) if `member_count > 0`, require `reassignToRoleId`, assert it exists in same tenant and is not the role being deleted, then `UPDATE tenant_memberships SET role_id = reassignToRoleId WHERE role_id = roleId AND tenant_id = tenantId` and collect affected `user_id`s; (4) delete `role_permissions WHERE role_id = roleId`; (5) delete the `roles` row. Return `{ reassignedUserIds: UserId[] }`.
- [ ] Use `tenantQuery` scoping so no query can cross tenant boundaries. All multi-statement operations MUST run inside one Drizzle transaction (`require-audit-in-transaction` cross-cutting rule).
**Schema / Interfaces:**
```ts
// Existing locked Drizzle tables (do NOT redefine; reference only):
//   roles (id UUID PK, tenant_id UUID FK->tenants(id), name TEXT, is_system_role BOOLEAN, created_at TIMESTAMPTZ)  UNIQUE(tenant_id, name)
//   permissions (id UUID PK, key TEXT, description TEXT)  UNIQUE(key)
//   role_permissions (role_id UUID FK->roles(id), permission_id UUID FK->permissions(id))  PK(role_id, permission_id)
//   tenant_memberships (user_id UUID, tenant_id UUID, role_id UUID FK->roles(id), status TEXT, freeze_reason TEXT, created_at TIMESTAMPTZ)  PK(user_id, tenant_id)

export interface CustomRoleDTO {
  id: string;
  name: string;
  is_system_role: boolean;
  permissions: string[];   // permission keys
  member_count: number;
}

export function listCustomRoles(db: Db, tenantId: string): Promise<CustomRoleDTO[]>;
export function createCustomRole(db: Db, tenantId: string, name: string, permissionKeys: string[]): Promise<CustomRoleDTO>;
export function updateCustomRole(db: Db, tenantId: string, roleId: string, patch: { name?: string; permissionKeys?: string[] }): Promise<CustomRoleDTO>;
export function deleteCustomRole(db: Db, tenantId: string, roleId: string, reassignToRoleId?: string): Promise<{ reassignedUserIds: string[] }>;

export class RoleNameConflictError extends Error {}
export class SystemRoleImmutableError extends Error {}
export class RoleHasMembersError extends Error {}   // thrown when delete needs reassign_to_role_id but none given
```
**Acceptance:**
- [ ] `createCustomRole` with a duplicate name in the same tenant throws `RoleNameConflictError`.
- [ ] `updateCustomRole`/`deleteCustomRole` on a `is_system_role = true` row throws `SystemRoleImmutableError`.
- [ ] `deleteCustomRole` with members and a valid `reassignToRoleId` moves all those memberships and removes the role in one transaction; with members and no `reassignToRoleId` throws `RoleHasMembersError`.
- [ ] All returned `permissions` are valid keys from the vocabulary; `member_count` matches `tenant_memberships`.

### Task 3: API routes for role management
**Blocks:** 4  ·  **Blocked by:** 1, 2
**Files:**
- Create: `apps/zync-api/src/routes/roles.ts`
- Modify: `apps/zync-api/src/index.ts` (mount router at `/api/roles`)
**Steps:**
- [ ] `GET /api/roles` — guard `requirePermission('users:manage')`; call `listCustomRoles(db, session.tenantId)`; return the array `[{ id, name, is_system_role, permissions, member_count }]`. (Superset-compatible with team-users-settings consumers reading `{ id, name, is_system_role }`.)
- [ ] `POST /api/roles` — guard `requirePermission('users:manage')`; validate body with `createRoleSchema` (`name` non-empty/unique-per-tenant enforced at DB level; `permissions` validated against vocabulary). On `RoleNameConflictError` return 409. Return 201 with the created role.
- [ ] `PATCH /api/roles/:id` — guard `requirePermission('users:manage')`; validate `updateRoleSchema` (`name?`, `permissions?`). On `SystemRoleImmutableError` return 403; on `RoleNameConflictError` return 409; on not-found return 404. Return 200 with updated role. After a successful permission/name change, call `bumpUserVersion` for every `user_id` whose membership uses this role so their next request re-expands permissions (token-version bump per auth-rbac KV revocation).
- [ ] `DELETE /api/roles/:id` — guard `requirePermission('users:manage')`; validate `deleteRoleSchema` (`reassign_to_role_id?`). Call `deleteCustomRole`; on `RoleHasMembersError` return 400 with `{ error: 'reassign_to_role_id required', member_count }`; on `SystemRoleImmutableError` return 403. After success, `bumpUserVersion` for each `reassignedUserIds`. Return 200.
- [ ] Every route uses Zod validation (`require-zod-validation-in-routes`) and the `authMiddleware`-populated session for `tenantId`; no raw Drizzle in routes (`no-raw-drizzle-from-routes`) — all DB access via Task 2 services.
- [ ] Enforce timing-safe / no-leak error bodies: never echo other tenants' data; 404 for cross-tenant ids rather than 403 detail.
**Schema / Interfaces:**
```ts
// apps/zync-api/src/routes/roles.ts
import { permissionsArraySchema, roleNameSchema } from '@zync/auth';
import { z } from 'zod';

const createRoleSchema = z.object({
  name: roleNameSchema,
  permissions: permissionsArraySchema,
});
const updateRoleSchema = z.object({
  name: roleNameSchema.optional(),
  permissions: permissionsArraySchema.optional(),
}).refine(b => b.name !== undefined || b.permissions !== undefined, { message: 'nothing to update' });
const deleteRoleSchema = z.object({
  reassign_to_role_id: z.string().uuid().optional(),
});

// Routes (all under requirePermission('users:manage')):
//   GET    /api/roles
//   POST   /api/roles            body: { name, permissions: string[] }                -> 201 CustomRoleDTO
//   PATCH  /api/roles/:id        body: { name?, permissions?: string[] }              -> 200 CustomRoleDTO
//   DELETE /api/roles/:id        body: { reassign_to_role_id?: string }               -> 200 { ok: true }
```
**Acceptance:**
- [ ] `POST /api/roles` with an invalid permission string returns 400; with a duplicate name returns 409.
- [ ] `PATCH`/`DELETE` on a system role returns 403; on a role from another tenant returns 404.
- [ ] `DELETE` of a role with members and no `reassign_to_role_id` returns 400 with `member_count`; with a valid one returns 200 and reassigns members.
- [ ] A successful `PATCH` triggers `bumpUserVersion` for affected members.

### Task 4: React Query hooks for roles
**Blocks:** 5, 6  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-app/src/features/settings/roles/api.ts`
- Create: `apps/zync-app/src/features/settings/roles/hooks.ts`
**Steps:**
- [ ] `useRolesList()` — `useQuery(['roles'], () => GET /api/roles)`; expose split selectors for system vs custom (`is_system_role`).
- [ ] `useCreateRole()` — `useMutation(POST /api/roles)`, invalidate `['roles']`, toast success/error.
- [ ] `useUpdateRole()` — `useMutation(PATCH /api/roles/:id)`, invalidate `['roles']`.
- [ ] `useDeleteRole()` — `useMutation(DELETE /api/roles/:id)` accepting `{ id, reassign_to_role_id? }`, invalidate `['roles']`.
- [ ] Type responses with the shared `CustomRoleDTO` shape; surface 409/400 errors as field-level messages (name conflict, reassign required).
**Schema / Interfaces:**
```ts
export interface RoleListItem {
  id: string; name: string; is_system_role: boolean;
  permissions: string[]; member_count: number;
}
export function useRolesList(): UseQueryResult<RoleListItem[]>;
export function useCreateRole(): UseMutationResult<RoleListItem, ApiError, { name: string; permissions: string[] }>;
export function useUpdateRole(): UseMutationResult<RoleListItem, ApiError, { id: string; name?: string; permissions?: string[] }>;
export function useDeleteRole(): UseMutationResult<void, ApiError, { id: string; reassign_to_role_id?: string }>;
```
**Acceptance:**
- [ ] Hooks call the correct endpoints and invalidate `['roles']` on mutation success.
- [ ] Name-conflict (409) and reassign-required (400) errors are exposed to the UI distinctly.

### Task 5: Roles list page (`/settings/roles`)
**Blocks:** 7  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/roles/RolesPage.tsx`
- Create: `apps/zync-app/src/features/settings/roles/DeleteRoleDialog.tsx`
- Modify: `apps/zync-app/src/router.tsx` (add `/settings/roles` route)
**Steps:**
- [ ] Route `/settings/roles`, guarded client-side by the `users:manage` permission check (and server-enforced by Task 3). Render via the settings layout with breadcrumb `Settings > Roles`.
- [ ] Render two `Card` sections: **System roles (read-only)** — list `OWNER`, `ADMIN`, `MEMBER`, `CONTRACTOR` (and `VIEWER`) with short descriptions and NO edit/delete controls; **Custom roles** — one row per custom role showing name, `member_count` (e.g. "4 members"), `[Edit]` and `[Delete]` actions.
- [ ] Header `[+ New role]` button opens the create modal (Task 6) in create mode; `[Edit]` opens it in edit mode pre-filled.
- [ ] Empty custom-roles state uses `EmptyState` ("No custom roles yet").
- [ ] `[Delete]` opens `DeleteRoleDialog`: if `member_count === 0`, simple confirm → `useDeleteRole({ id })`. If `member_count > 0`, show member count and a required replacement-role `Select` populated from the list (custom + system roles, excluding the role being deleted); confirm → `useDeleteRole({ id, reassign_to_role_id })`. Copy: "Delete \"{name}\"? This role is assigned to {n} members. Reassign members to: [Select]". Buttons "Cancel" / "Delete and reassign".
- [ ] All interactive controls keyboard-operable; dialog uses focus trap and `aria-modal`; destructive action button labeled clearly; honor `prefers-reduced-motion` for any transitions; RTL-aware layout via `useDirection`.
**Acceptance:**
- [ ] System roles render without edit/delete affordances; custom roles render with both.
- [ ] Deleting a 0-member role asks for simple confirmation; deleting an N-member role forces selecting a replacement and disables confirm until one is chosen.
- [ ] Page is reachable only with `users:manage`; unauthorized users do not see it.

### Task 6: Create / Edit role modal with permission matrix
**Blocks:** 7  ·  **Blocked by:** 4
**Files:**
- Create: `apps/zync-app/src/features/settings/roles/RoleFormModal.tsx`
- Create: `apps/zync-app/src/features/settings/roles/PermissionMatrix.tsx`
**Steps:**
- [ ] `RoleFormModal` (`Dialog`) supports create and edit modes. Fields: **Role name** (`Input`, required) and the permission matrix.
- [ ] `PermissionMatrix` renders one bordered group per entry in `PERMISSION_GROUPS` (imported from `@zync/auth`), each group a `fieldset`/`legend` with a `Checkbox` per permission key. Show ALL permissions regardless of tenant's active features (per spec architecture decision).
- [ ] Controlled selected-permissions `Set<string>`; pre-populate from the role's `permissions` in edit mode.
- [ ] On submit: validate name non-empty; call `useCreateRole` or `useUpdateRole`. On 409 name conflict, show inline name error. Close on success; toast.
- [ ] a11y: each `Checkbox` has an associated label; groups use `<fieldset><legend>`; modal focus-trapped with labelled title; checkboxes operable by keyboard; reduced-motion honored; RTL mirrored.
**Schema / Interfaces:**
```ts
interface RoleFormModalProps {
  mode: 'create' | 'edit';
  role?: RoleListItem;          // required in edit mode
  open: boolean;
  onOpenChange: (open: boolean) => void;
}
// Submit payloads:
//   create -> { name, permissions: string[] }
//   edit   -> { id, name, permissions: string[] }
```
**Acceptance:**
- [ ] Matrix shows every group/permission from `PERMISSION_GROUPS`; checking/unchecking updates the selected set.
- [ ] Edit mode pre-checks the role's current permissions; saving sends the full permission array.
- [ ] Name-conflict error surfaces inline on the name field.

### Task 7: i18n strings + RTL/Hebrew
**Blocks:** —  ·  **Blocked by:** 5, 6
**Files:**
- Modify: `packages/config/src/locales/en.json` (or app locale files) — add `settings.roles.*` keys
- Modify: `packages/config/src/locales/he.json` — Hebrew translations
**Steps:**
- [ ] Add translation keys for: page title, section headers (System roles / Custom roles), `+ New role`, `Edit`, `Delete`, member-count plural, all delete-dialog copy, modal labels (Role name, Save role, Cancel), permission-group labels, and error messages (name required, name taken, reassign required).
- [ ] Provide Hebrew translations; verify permission-group labels and member-count pluralization render correctly RTL.
- [ ] Wire all visible strings in Tasks 5 & 6 through the i18n layer (no hardcoded copy).
**Acceptance:**
- [ ] No hardcoded user-facing strings remain in the roles feature; switching locale to Hebrew renders translated, RTL-correct UI.

### Task 8: Tests (API + DB service)
**Blocks:** —  ·  **Blocked by:** 3
**Files:**
- Create: `apps/zync-api/test/roles.test.ts`
- Create: `packages/db/test/roles-service.test.ts`
**Steps:**
- [ ] DB service tests: create/edit/delete happy paths; duplicate-name conflict; system-role immutability guard; delete-with-members reassignment transaction (assert all memberships moved and role gone); permission-key validation rejects unknown keys.
- [ ] API tests: each route enforces `users:manage` (403 without); `POST` validation 400/409; `PATCH`/`DELETE` system-role 403 and cross-tenant 404; `DELETE` member reassign 400-without-id then 200-with-id; assert `bumpUserVersion` invoked for affected members on PATCH and delete-reassign.
- [ ] Assert tenant isolation: a role from tenant B is never visible/mutable from tenant A's session.
**Acceptance:**
- [ ] All tests pass; coverage includes permission-guard, validation, system-role guard, tenant isolation, and atomic delete-reassign.
