# RBAC deux plans — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Implémenter la spec `docs/superpowers/specs/2026-07-09-rbac-roles-staff-permissions-design.md` : rôles org corrigés (member/billing/GSC) + plan système à permissions atomiques (Staff SYS) + population SYS dissociée + back-office regaté.

**Architecture:** Deux plans disjoints. Plan ORG : access-control Better Auth existant (`src/lib/permissions.ts`), rôles `owner`/`admin`/`member` (ex-`staff`). Plan SYSTÈME : `SUPER_ADMIN_EMAILS` (Admin SYS, inchangé) + `user.role="staff"` adossé à une table `staff_permission` ; gate unique `assertSysPermission` ; `adminRoles` Better Auth reste `["admin"]` (aucun endpoint brut pour le staff). Les opérateurs SYS sont orgless et confinés à `/admin` par routage.

**Tech Stack:** Next.js 16 App Router, Better Auth 1.6.17 (plugins organization/admin/twoFactor), Drizzle + Postgres, bun test, biome, next-intl (catalogues `messages/{en,fr,es,de}.json`).

## Global Constraints

- **Branche** : tout se passe sur `feature/rbac-two-planes` (créée depuis `dev` à jour). Merge final : `--ff-only` dans `dev`. **JAMAIS `main`.**
- **Commits** : Conventional Commits en français (`feat:`/`fix:`/`refactor:`/`docs:`/`test:`/`chore:`), un commit par tâche minimum, avec les trailers :
  `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` et `Claude-Session: https://claude.ai/code/session_01FdJ7KB3nYb1UShR7Eob8qi`
- **Gate par tâche** : `bunx biome check <fichiers touchés>` propre + `bun run test:all` vert (c'est le SEUL runner fiable — `bun test` direct fuit les `mock.module` entre fichiers).
- **Tests DB** : base `seo_insight_test` uniquement. Avant la première tâche à migration : `bun run db:migrate:test`. Ne JAMAIS pointer un test sur `seo_insight_dev` (un `DATABASE_URL` exporté dans le shell écrase `.env.test` — les scripts npm forcent la bonne URL, utilise-les).
- **Migrations custom (data-only)** : fichier SQL manuscrit dans `drizzle/` avec séparateur `--> statement-breakpoint`, PAS de snapshot, + entrée dans `drizzle/meta/_journal.json` (`{"idx": N, "version": "7", "when": <ms epoch>, "tag": "<nom sans .sql>", "breakpoints": true}`). Migrations DDL depuis le schéma : `bun run db:generate`.
- **`"staff"` a deux sens après ce plan** : `user.role = "staff"` = opérateur SYS (plan système) ; le rôle d'ORG `staff` n'existe plus (renommé `member`). Ne jamais mélanger les colonnes `user.role` et `member.role`.
- **NE PAS TOUCHER** au domaine « support staff » (personnel du support) : `src/app/(app)/support/staff/**`, `src/components/support/staff-*.tsx`, `src/lib/support/**` (`senderType: "staff"`), clé i18n `staffInbox`. C'est un autre concept.
- Fichiers >100 lignes : localiser d'abord (Grep), puis Read offset+limit. Edit, pas Write, sur l'existant.

---

## Setup (avant Task 1)

```bash
git checkout dev && git pull --ff-only
git checkout -b feature/rbac-two-planes
bun run db:migrate:test   # base de test au niveau 0060
```

---

### Task 1: Rename du rôle org `staff` → `member` (code + data, unité atomique)

Le renommage code et la migration data doivent partir dans le MÊME commit : la map `roles` Better Auth et les valeurs en base doivent rester synchrones.

**Files:**
- Create: `drizzle/0061_org_role_member.sql`
- Modify: `drizzle/meta/_journal.json`
- Modify: `src/lib/permissions.ts`
- Modify: `src/lib/auth.ts:53` (import) et `src/lib/auth.ts:461` (roles map)
- Modify: `src/lib/auth-client.ts:12,16`
- Modify: `src/lib/admin/org-actions.ts:33,36,134,230`
- Modify: `src/components/team-management.tsx:66,87-91,102,700,717,754,760`
- Modify: `src/components/onboarding/steps/team-step.tsx:26,43,99`
- Modify: `src/components/admin/org-detail.tsx:180,196,343,372,378`
- Modify: `src/app/(app)/settings/team/page.tsx:55`
- Modify: `src/app/accept-invitation/[id]/page.tsx:55`
- Modify: `src/components/accept-invitation.tsx:19,105,249`
- Modify: `src/lib/auth/username-policy.ts:26` (ajouter `"member"` à la liste réservée, GARDER `"staff"`)
- Modify: `messages/en.json:152`, `messages/es.json:152` (texte `orgAddMemberBody` : "staff" → "member")
- Tests à mettre à jour (valeur de rôle `"staff"` → `"member"`) : `src/lib/admin/org-management/org-actions.test.ts` (l.25,139,205,212,244,248,272,280,288,295,305,323), `src/lib/auth/seat-guard.test.ts:73,175,178`, `src/lib/billing/authorize.test.ts:71,89`, `src/lib/billing/pack.test.ts:144,199`, `src/lib/billing/seat-unblock.test.ts:179`, `src/lib/onboarding/activation/derive.test.ts:152,164`, `src/lib/gsc/actions.test.ts:9,99`
- Commentaires à toiletter (mention du rôle org staff) : `src/lib/db/domain-schema.ts:98,699,700`, `src/lib/security/tenancy-guard.ts:32`, `src/lib/sites/access-actions.ts:1`, `src/lib/sites/access.ts:1,3,81,93,99`, `src/lib/sites/actions.ts:42,119,138`, `src/lib/sites/actions-gate.test.ts:3`, `src/lib/keywords/import/brief-by-term-actions.ts:16`, `src/lib/keywords/prompt/actions.ts:34,35`, `src/lib/db/auth-schema.ts:33`, `src/lib/admin/org-actions.ts:219`, `src/app/(app)/backlink/profile/import/route.ts:56,57`, `src/app/(app)/keywords/import-heavy/route.ts:74,75`, `src/app/(app)/keywords/serp/import/route.ts:42,43`, `src/components/team-member-sites-dialog.tsx:2`, `src/components/admin/org-detail.tsx:170`

**Interfaces:**
- Produces: export `member` (rôle AC) dans `src/lib/permissions.ts`, `roles = { owner, admin, member }`. La string de rôle org valide devient `"owner" | "admin" | "member"`. Les tâches 2 et 3 modifient ce même fichier ensuite.

- [ ] **Step 1: Écrire la migration data**

Créer `drizzle/0061_org_role_member.sql` :

```sql
-- RBAC spec 2026-07-09 §3 — rename du rôle d'organisation le plus bas :
-- "staff" → "member". Data-only (aucun DDL). La string vit dans member.role
-- ET dans invitation.role (les invitations pendantes doivent suivre, sinon
-- l'acceptation créerait un membre avec un rôle inconnu de la roles map).
-- NB : user.role est une AUTRE colonne (plan système) — non touchée.
UPDATE "member" SET "role" = 'member' WHERE "role" = 'staff';
--> statement-breakpoint
UPDATE "invitation" SET "role" = 'member' WHERE "role" = 'staff';
```

Ajouter à `drizzle/meta/_journal.json` (dans `entries`, après l'idx 60) :

```json
{ "idx": 61, "version": "7", "when": <sortie de `date +%s%3N`>, "tag": "0061_org_role_member", "breakpoints": true }
```

- [ ] **Step 2: Appliquer sur la base de test**

Run: `bun run db:migrate:test`
Expected: migration 0061 appliquée sans erreur (0 lignes affectées sur une base de test fraîche — normal).

- [ ] **Step 3: Renommer le rôle dans permissions.ts**

Remplacer dans `src/lib/permissions.ts` (lignes 2-3, 19-20, 34) :

```ts
// Organization access-control: roles owner | admin | member.
// Billing is reserved to owner/admin — member has no billing access.
```

```ts
/** member = default member rights, NO billing. */
export const member = ac.newRole({ ...memberAc.statements });
```

```ts
export const roles = { owner, admin, member };
```

- [ ] **Step 4: Mettre à jour les deux roles maps (serveur + client)**

`src/lib/auth.ts:53` : `import { ac, admin, member, owner } from "./permissions";`
`src/lib/auth.ts:461` : `roles: { owner, admin, member },`
`src/lib/auth-client.ts:12` : `import { ac, admin, member, owner } from "./permissions";`
`src/lib/auth-client.ts:16` : `organizationClient({ ac, roles: { owner, admin, member } }),`

⚠️ Si `bunx tsc` signale une collision de symbole `member` dans `auth.ts` ou `auth-client.ts` (import d'une table `member` ailleurs dans le fichier), aliaser : `import { member as memberRole } from "./permissions"` et `roles: { owner, admin, member: memberRole }`.

- [ ] **Step 5: Balayer les schémas zod, types union et valeurs UI**

Dans chaque fichier listé (sections zod/UI ci-dessus), remplacer la STRING de rôle org `"staff"` par `"member"`. Exemples exacts :
- `org-actions.ts:33` → `z.enum(["owner", "admin", "member"])` ; `:36` → `z.enum(["admin", "member"])` ; signatures l.134/230 idem.
- `team-management.tsx:66` → `type OrgRole = "owner" | "admin" | "member";` ; `:90` → `member: "Membre",` (clé de la map `ROLE_LABELS`) ; `:102` → `["member", "admin"]` ; `:700/754` → `"member" | "admin"` ; `:717` → `setRole("member")` ; `:760` → `<SelectItem value="member">Membre</SelectItem>`.
- `team-step.tsx:26` → `type Role = "member" | "admin";` ; `:43` → `useState<Role>("member")` ; `:99` → `<SelectItem value="member">{t("teamRoleStaff")}</SelectItem>` (la CLÉ i18n `teamRoleStaff` ne change pas — seuls les libellés dans les 4 catalogues doivent dire Member/Membre/Miembro/Mitglied, vérifier leur valeur).
- `org-detail.tsx` : mêmes substitutions (l.180,196,343,372,378).
- Fallbacks : `settings/team/page.tsx:55` → `?? "member"` ; `accept-invitation/[id]/page.tsx:55` → `?? "member"` ; `accept-invitation.tsx:19` → clé de map `member: "roleStaff",` ; `:105,249` → `?? "member"`.
- `username-policy.ts:26` : ajouter `"member",` à la liste (garder `"staff"`).
- `messages/en.json:152` : remplacer le mot "staff" par "member" dans `orgAddMemberBody` ; `messages/es.json:152` idem ("staff" → "member").

- [ ] **Step 6: Mettre à jour les tests listés**

Dans les 7 fichiers de tests listés, remplacer les valeurs de rôle org `"staff"` par `"member"` aux lignes indiquées. ⚠️ Ne PAS toucher les identifiants du style `const STAFF = "test-xxx-staff"` (ce sont des userId cosmétiques, pas des rôles).

- [ ] **Step 7: Toiletter les commentaires listés** (mention « staff » → « member » quand il s'agit du rôle org).

- [ ] **Step 8: Gate**

Run: `bunx biome check src/ && bun run test:all`
Expected: PASS.
Run: `grep -rn '"staff"' src --include='*.ts' --include='*.tsx' | grep -v support | grep -v senderType | grep -v username-policy`
Expected: AUCUN résultat (le sens SYS de "staff" n'existe pas encore — il arrive en Task 5/6).

- [ ] **Step 9: Commit**

```bash
git add -A && git commit -m "refactor(rbac): rename du rôle org staff → member (code + migration 0061)"
```

---

### Task 2: Billing owner-only (plan org)

**Files:**
- Modify: `src/lib/permissions.ts` (rôle `admin`)
- Modify: `src/lib/billing/authorize.ts` (`isOrgBillingManager`)
- Test: `src/lib/billing/authorize.test.ts`

**Interfaces:**
- Consumes: `member` renommé en Task 1.
- Produces: `isOrgBillingManager(userId, orgId)` retourne `true` UNIQUEMENT pour le rôle org `owner`. Le rôle AC `admin` n'a plus le statement `billing`.

- [ ] **Step 1: Test rouge**

Dans `src/lib/billing/authorize.test.ts`, modifier le cas existant qui affirme qu'un `admin` peut gérer le billing pour affirmer l'inverse (`expect(await isOrgBillingManager(ADMIN_ID, ORG_ID)).toBe(false)`), et garder le cas `owner` → `true`.

Run: `bun run test src/lib/billing/authorize.test.ts` — Expected: FAIL (admin retourne encore true).

- [ ] **Step 2: Implémentation**

`src/lib/billing/authorize.ts` : remplacer `inArray(member.role, ["owner", "admin"])` par `eq(member.role, "owner")` (adapter les imports drizzle : retirer `inArray`, garder `eq`). Mettre à jour le doc-comment du fichier (« only org owners ») — c'est le guard `authorizeReference` du plugin Stripe ET le gate de `settings/billing`/`billing-locked`.

`src/lib/permissions.ts` : le rôle `admin` perd `billing` :

```ts
/** admin = default admin rights. NO billing (owner-only — spec RBAC §3). */
export const admin = ac.newRole({
  ...adminAc.statements,
});
```

- [ ] **Step 3: Gate + commit**

Run: `bun run test:all` — Expected: PASS (si d'autres tests supposaient admin-billing, les corriger dans ce commit).

```bash
git add -A && git commit -m "feat(rbac): billing réservé à l'owner de l'org (retiré du rôle admin)"
```

---

### Task 3: GSC linking ouvert à l'admin org (`gsc:link`)

**Files:**
- Modify: `src/lib/permissions.ts` (statement `gsc`)
- Modify: `src/lib/gsc/owner.ts` (nouveau `canLinkGsc`, fallback token)
- Modify: `src/lib/gsc/actions.ts` (~l.59-74, expose `canLink`)
- Modify: `src/components/gsc-integrations.tsx` (~l.59-64, gate du bouton connexion)
- Test: `src/lib/gsc/actions.test.ts`

**Interfaces:**
- Produces: `canLinkGsc(): Promise<boolean>` (owner OU admin) dans `src/lib/gsc/owner.ts` ; `getOrgOwnerGscToken` résout owner d'abord, sinon un admin avec compte Google lié ; le statut exposé par `gsc/actions.ts` porte un champ `canLink: boolean`.

- [ ] **Step 1: Statement AC**

`src/lib/permissions.ts` :

```ts
export const statement = {
  ...defaultStatements,
  billing: ["view", "manage"],
  // Lier Google Search Console (connexion du compte + mapping de propriété).
  gsc: ["link"],
} as const;
```

et donner `gsc: ["link"]` aux rôles `owner` et `admin` (PAS `member`) :

```ts
export const admin = ac.newRole({
  ...adminAc.statements,
  gsc: ["link"],
});

export const owner = ac.newRole({
  ...ownerAc.statements,
  billing: ["view", "manage"],
  gsc: ["link"],
});
```

- [ ] **Step 2: Test rouge**

`src/lib/gsc/actions.test.ts` utilise une variable `ROLE` (l.9, renommée en Task 1 vers `"owner" | "admin" | "member"`). Ajouter un cas : `ROLE = "admin"` → la connexion GSC est autorisée (le statut expose `canLink === true`), et `ROLE = "member"` → refusée.

Run: `bun run test src/lib/gsc/actions.test.ts` — Expected: FAIL.

- [ ] **Step 3: Implémentation dans owner.ts**

Ajouter sous `isGscOwner` (l.31-36) — même style que `canManageGsc` (l.21-26) :

```ts
/** Qui peut LIER Search Console (connexion du compte Google + mapping) :
 *  owner et admin — miroir du statement AC `gsc:link` (permissions.ts). */
export async function canLinkGsc(): Promise<boolean> {
  const m = await auth.api.getActiveMember({
    headers: await requestHeaders(),
  });
  return m?.role === "owner" || m?.role === "admin";
}
```

Puis étendre `getOrgOwnerGscToken` (l.44-76) : la requête qui cherche le compte Google lié filtre aujourd'hui `member.role === "owner"`. La passer sur `inArray(member.role, ["owner", "admin"])` avec un tri qui préfère l'owner (ex. `ORDER BY CASE WHEN role = 'owner' THEN 0 ELSE 1 END`), en CONSERVANT les champs sélectionnés existants. Raison : si c'est un admin qui a connecté son compte Google, le worker doit trouver son token — sinon le lien admin serait inopérant. Mettre à jour le doc-comment.

- [ ] **Step 4: Exposer et consommer `canLink`**

`src/lib/gsc/actions.ts` (~l.59-74) : ajouter `canLink: await canLinkGsc()` au statut retourné (garder `isOwner` pour compat). `src/components/gsc-integrations.tsx` (~l.59-64) : le bouton « Connecter » se gate sur `status.canLink` au lieu de `status.isOwner`.

- [ ] **Step 5: Gate + commit**

Run: `bun run test:all` — Expected: PASS.

```bash
git add -A && git commit -m "feat(rbac): lien GSC ouvert à l'admin org (statement gsc:link + fallback token)"
```

---

### Task 4: Table `staff_permission` (schéma + migration générée)

**Files:**
- Modify: `src/lib/db/admin-schema.ts` (nouvelle table, à côté d'`admin_audit_log`)
- Create (généré): `drizzle/0062_*.sql` via `bun run db:generate`

**Interfaces:**
- Produces: export Drizzle `staffPermission` avec colonnes `userId` (FK user, cascade), `permission` (text), `grantedBy` (FK user, set null), `grantedAt` (timestamptz default now), PK composite `(userId, permission)`.

- [ ] **Step 1: Schéma**

Dans `src/lib/db/admin-schema.ts`, ajouter (importer `primaryKey` depuis `drizzle-orm/pg-core` si absent) :

```ts
/** RBAC SYS (spec 2026-07-09 §5) — une ligne par permission atomique accordée
 *  à un opérateur Staff SYS (user.role = "staff"). L'Admin SYS (allow-list
 *  env) détient tout implicitement et n'apparaît JAMAIS ici. Le vocabulaire
 *  des permissions vit dans src/lib/auth/sys-permissions.ts. */
export const staffPermission = pgTable(
  "staff_permission",
  {
    userId: text("user_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    permission: text("permission").notNull(),
    grantedBy: text("granted_by").references(() => user.id, {
      onDelete: "set null",
    }),
    grantedAt: timestamp("granted_at", { withTimezone: true })
      .defaultNow()
      .notNull(),
  },
  (t) => [primaryKey({ columns: [t.userId, t.permission] })],
);
```

- [ ] **Step 2: Générer + appliquer**

Run: `bun run db:generate` — Expected: nouvelle migration `0062_*.sql` créant `staff_permission`. ⚠️ Si le diff généré contient AUTRE CHOSE que cette table (drift de snapshot dû aux migrations manuelles passées), STOP : ne garder que le CREATE TABLE + contraintes dans le SQL généré, supprimer les statements parasites, et signaler le drift dans le rapport de tâche.

Run: `bun run db:migrate:test` — Expected: OK.

- [ ] **Step 3: Commit**

```bash
git add -A && git commit -m "feat(rbac): table staff_permission (grants atomiques du plan système)"
```

---

### Task 5: Module `sys-permissions` (vocabulaire + gate)

**Files:**
- Create: `src/lib/auth/sys-permissions.ts`
- Test: `src/lib/auth/sys-permissions.test.ts`

**Interfaces:**
- Consumes: `staffPermission` (Task 4), `isSuperAdmin` (`src/lib/auth/super-admin.ts`).
- Produces (consommé par les Tasks 6-12) :
  - `SYS_PERMISSION_GROUPS: Record<"users"|"orgs"|"billing"|"data", readonly string[]>`
  - `GRANTABLE_SYS_PERMISSIONS: readonly GrantableSysPermission[]` (13 permissions)
  - `RESERVED_SYS_PERMISSIONS = ["staff:manage", "users:impersonate"] as const`
  - `type SysPermission`, `type GrantableSysPermission`
  - `isGrantableSysPermission(p: string): p is GrantableSysPermission`
  - `resolveSysPermission(op, perm, grants): "ok" | "forbidden" | "2fa-required"` (pur)
  - `getSysOperatorSession(): Promise<SysOperatorSession | null>` avec `SysOperatorSession = { userId: string; email: string; kind: "admin-sys" | "staff-sys"; twoFactorEnabled: boolean }`
  - `isCurrentUserSysOperator(): Promise<boolean>`
  - `assertSysPermission(perm: SysPermission): Promise<void>`
  - `currentSysPermissions(): Promise<Set<SysPermission>>`

- [ ] **Step 1: Tests rouges (matrice pure)**

Créer `src/lib/auth/sys-permissions.test.ts` :

```ts
import { describe, expect, test } from "bun:test";
import {
  GRANTABLE_SYS_PERMISSIONS,
  isGrantableSysPermission,
  RESERVED_SYS_PERMISSIONS,
  resolveSysPermission,
  SYS_PERMISSION_GROUPS,
} from "./sys-permissions";

describe("sys permission vocabulary", () => {
  test("13 grantable permissions across 4 groups", () => {
    expect(GRANTABLE_SYS_PERMISSIONS).toHaveLength(13);
    expect(Object.keys(SYS_PERMISSION_GROUPS)).toEqual([
      "users",
      "orgs",
      "billing",
      "data",
    ]);
  });
  test("reserved permissions are never grantable", () => {
    for (const p of RESERVED_SYS_PERMISSIONS) {
      expect(isGrantableSysPermission(p)).toBe(false);
    }
    expect(isGrantableSysPermission("users:impersonate")).toBe(false);
    expect(isGrantableSysPermission("staff:manage")).toBe(false);
    expect(isGrantableSysPermission("users:ban")).toBe(true);
  });
});

describe("resolveSysPermission (gate matrix)", () => {
  const admin = {
    userId: "a",
    email: "a@x.co",
    kind: "admin-sys" as const,
    twoFactorEnabled: true,
  };
  const staff = { ...admin, kind: "staff-sys" as const };
  test("no operator → forbidden", () => {
    expect(resolveSysPermission(null, "users:read", new Set())).toBe(
      "forbidden",
    );
  });
  test("operator without 2FA → 2fa-required (admin AND staff)", () => {
    expect(
      resolveSysPermission(
        { ...admin, twoFactorEnabled: false },
        "users:read",
        new Set(),
      ),
    ).toBe("2fa-required");
    expect(
      resolveSysPermission(
        { ...staff, twoFactorEnabled: false },
        "users:read",
        new Set(["users:read"]),
      ),
    ).toBe("2fa-required");
  });
  test("admin-sys → ok for everything, even reserved", () => {
    expect(resolveSysPermission(admin, "staff:manage", new Set())).toBe("ok");
    expect(resolveSysPermission(admin, "users:impersonate", new Set())).toBe(
      "ok",
    );
  });
  test("staff-sys → ok only with the exact grant", () => {
    expect(
      resolveSysPermission(staff, "users:ban", new Set(["users:ban"])),
    ).toBe("ok");
    expect(
      resolveSysPermission(staff, "users:ban", new Set(["users:read"])),
    ).toBe("forbidden");
  });
  test("staff-sys never passes a reserved permission, even if a rogue row exists", () => {
    expect(
      resolveSysPermission(staff, "staff:manage", new Set(["staff:manage"])),
    ).toBe("forbidden");
    expect(
      resolveSysPermission(
        staff,
        "users:impersonate",
        new Set(["users:impersonate"]),
      ),
    ).toBe("forbidden");
  });
});
```

Run: `bun run test src/lib/auth/sys-permissions.test.ts` — Expected: FAIL (module absent).

- [ ] **Step 2: Implémentation complète**

Créer `src/lib/auth/sys-permissions.ts` :

```ts
// src/lib/auth/sys-permissions.ts — plan SYSTÈME : permissions atomiques.
// Généralise super-admin.ts (spec 2026-07-09) :
//   Admin SYS (env SUPER_ADMIN_EMAILS) → toutes les permissions, implicitement.
//   Staff SYS (user.role = "staff")    → sous-ensemble en DB (staff_permission).
// Ce gate est le SEUL chemin d'autorisation du back-office : adminRoles Better
// Auth reste ["admin"], un Staff SYS ne peut pas frapper /api/auth/admin/* en
// direct (spec §5/§8.6).
import "server-only";
import { and, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { staffPermission } from "@/lib/db/admin-schema";
import { db } from "@/lib/db/client";
import { isSuperAdmin } from "./super-admin";

/** Permissions assignables à un Staff SYS, groupées par ressource — source de
 *  vérité de la grille UI (/admin/staff) ET du gate. */
export const SYS_PERMISSION_GROUPS = {
  users: [
    "users:read",
    "users:ban",
    "users:role",
    "users:2fa-reset",
    "users:email",
    "users:sessions",
  ],
  orgs: ["orgs:read", "orgs:members", "orgs:limits"],
  billing: ["billing:read", "billing:manage"],
  data: ["export:run", "audit:read"],
} as const;

export type GrantableSysPermission =
  (typeof SYS_PERMISSION_GROUPS)[keyof typeof SYS_PERMISSION_GROUPS][number];

export const GRANTABLE_SYS_PERMISSIONS: readonly GrantableSysPermission[] =
  Object.values(SYS_PERMISSION_GROUPS).flat();

/** Jamais délégables (anti-escalade, spec §4) : staff:manage permettrait
 *  l'auto-promotion ; users:impersonate fait hériter TOUS les droits org de la
 *  cible (court-circuite le RBAC système). Admin SYS uniquement. */
export const RESERVED_SYS_PERMISSIONS = [
  "staff:manage",
  "users:impersonate",
] as const;

export type SysPermission =
  | GrantableSysPermission
  | (typeof RESERVED_SYS_PERMISSIONS)[number];

export function isGrantableSysPermission(
  p: string,
): p is GrantableSysPermission {
  return (GRANTABLE_SYS_PERMISSIONS as readonly string[]).includes(p);
}

export interface SysOperatorSession {
  userId: string;
  email: string;
  kind: "admin-sys" | "staff-sys";
  twoFactorEnabled: boolean;
}

/** Résolution PURE du gate (testable sans DB/session). Un staff ne passe
 *  JAMAIS une permission réservée, même si une ligne fantôme existait en DB. */
export function resolveSysPermission(
  op: SysOperatorSession | null,
  perm: SysPermission,
  staffGrants: ReadonlySet<string>,
): "ok" | "forbidden" | "2fa-required" {
  if (!op) return "forbidden";
  if (!op.twoFactorEnabled) return "2fa-required";
  if (op.kind === "admin-sys") return "ok";
  if ((RESERVED_SYS_PERMISSIONS as readonly string[]).includes(perm)) {
    return "forbidden";
  }
  return staffGrants.has(perm) ? "ok" : "forbidden";
}

/** Session fraîche (jamais le cookie cache 5 min — même règle que god-mode) →
 *  l'opérateur SYS courant, ou null. */
export async function getSysOperatorSession(): Promise<SysOperatorSession | null> {
  const session = await auth.api.getSession({
    headers: await headers(),
    query: { disableCookieCache: true },
  });
  const u = session?.user;
  if (!u) return null;
  const twoFactorEnabled = Boolean(
    (u as { twoFactorEnabled?: boolean }).twoFactorEnabled,
  );
  if (isSuperAdmin(u.email)) {
    return { userId: u.id, email: u.email, kind: "admin-sys", twoFactorEnabled };
  }
  const role = (u as { role?: string | null }).role;
  if (role === "staff") {
    return { userId: u.id, email: u.email, kind: "staff-sys", twoFactorEnabled };
  }
  return null;
}

export async function isCurrentUserSysOperator(): Promise<boolean> {
  return (await getSysOperatorSession()) != null;
}

async function staffGrantsOf(userId: string): Promise<Set<string>> {
  const rows = await db
    .select({ permission: staffPermission.permission })
    .from(staffPermission)
    .where(eq(staffPermission.userId, userId));
  return new Set(rows.map((r) => r.permission));
}

/** LE gate du back-office (spec §6). Throw si non-opérateur, 2FA absent, ou
 *  permission non détenue. Lecture DB à chaque appel : une révocation prend
 *  effet immédiatement. */
export async function assertSysPermission(perm: SysPermission): Promise<void> {
  const op = await getSysOperatorSession();
  const grants =
    op?.kind === "staff-sys" ? await staffGrantsOf(op.userId) : new Set<string>();
  const verdict = resolveSysPermission(op, perm, grants);
  if (verdict === "2fa-required") {
    throw new Error("Forbidden: 2FA required for the admin surface");
  }
  if (verdict !== "ok") {
    throw new Error(`Forbidden: missing sys permission ${perm}`);
  }
}

/** Permissions effectives de l'opérateur courant (nav/boutons de l'UI admin).
 *  Vide pour un non-opérateur ou sans 2FA. */
export async function currentSysPermissions(): Promise<Set<SysPermission>> {
  const op = await getSysOperatorSession();
  if (!op || !op.twoFactorEnabled) return new Set();
  if (op.kind === "admin-sys") {
    return new Set<SysPermission>([
      ...GRANTABLE_SYS_PERMISSIONS,
      ...RESERVED_SYS_PERMISSIONS,
    ]);
  }
  const grants = await staffGrantsOf(op.userId);
  return new Set(
    [...grants].filter(isGrantableSysPermission),
  ) as Set<SysPermission>;
}
```

- [ ] **Step 3: Gate + commit**

Run: `bun run test src/lib/auth/sys-permissions.test.ts` puis `bun run test:all` — Expected: PASS.

```bash
git add -A && git commit -m "feat(rbac): module sys-permissions — vocabulaire atomique + gate assertSysPermission"
```

---

### Task 6: Population SYS — exemption bootstrap, routage, page 2FA

**Files:**
- Modify: `src/lib/auth/org-bootstrap.ts` (helper pur `shouldBootstrapOrg`)
- Modify: `src/lib/auth.ts` (hook `user.create.after`, ~l.433-436)
- Modify: `src/app/(app)/layout.tsx` (redirect SYS avant le gate onboarding, ~l.30-41)
- Create: `src/app/staff-2fa/page.tsx`
- Test: `src/lib/auth/org-bootstrap.test.ts` (créer si absent)
- Modify: `messages/en.json`, `messages/fr.json`, `messages/es.json`, `messages/de.json` (namespace `SuperAdmin`, clés `staffTwoFaTitle`, `staffTwoFaBody`)

**Interfaces:**
- Consumes: `getSysOperatorSession` (Task 5), `isSuperAdmin`, `TwoFactorSettings({ enabled: boolean })` (`src/components/two-factor-settings.tsx:43`).
- Produces: `shouldBootstrapOrg(role: string | null | undefined): boolean` exporté d'`org-bootstrap.ts` ; route `/staff-2fa` (hors groupes `(app)`/`(superadmin)`).

- [ ] **Step 1: Test rouge du helper**

`src/lib/auth/org-bootstrap.test.ts` :

```ts
import { describe, expect, test } from "bun:test";
import { shouldBootstrapOrg } from "./org-bootstrap";

describe("shouldBootstrapOrg", () => {
  test("SYS staff accounts never get a personal org", () => {
    expect(shouldBootstrapOrg("staff")).toBe(false);
  });
  test("everyone else does (user, admin, null, undefined)", () => {
    expect(shouldBootstrapOrg("user")).toBe(true);
    expect(shouldBootstrapOrg("admin")).toBe(true);
    expect(shouldBootstrapOrg(null)).toBe(true);
    expect(shouldBootstrapOrg(undefined)).toBe(true);
  });
});
```

Run — Expected: FAIL. ⚠️ Si `org-bootstrap.ts` importe des modules server-only en chaîne qui cassent le test hors runner, mettre le helper dans un fichier dédié `src/lib/auth/sys-account.ts` et adapter les imports.

- [ ] **Step 2: Helper + exemption dans le hook**

`org-bootstrap.ts` — ajouter en haut du fichier :

```ts
/** Population SYS (spec §2.bis) : un compte créé avec le rôle SYSTÈME "staff"
 *  est orgless par construction — jamais de bootstrap d'org personnelle.
 *  NB : "staff" ici = user.role (plan système), pas le rôle d'org member. */
export function shouldBootstrapOrg(
  role: string | null | undefined,
): boolean {
  return role !== "staff";
}
```

`src/lib/auth.ts` (~l.433-436), remplacer le `after` :

```ts
        // First sign-in: create a personal org + owner membership — SAUF pour
        // les comptes SYS (role "staff", provisionnés depuis /admin/staff) qui
        // sont orgless par construction (spec RBAC §2.bis).
        after: async (user) => {
          if (!shouldBootstrapOrg((user as { role?: string | null }).role))
            return;
          await createOrgForUser(user.id, user.name ?? user.email);
        },
```

(importer `shouldBootstrapOrg` à côté de l'import `createOrgForUser` l.26). Le hook `session.create.before` (l.449-455) tolère déjà l'orgless (`resolveDefaultOrgId` → null → session sans org) : ne pas y toucher.

- [ ] **Step 3: Redirect SYS dans le layout (app)**

`src/app/(app)/layout.tsx` — insérer ENTRE le gate session (l.31-33) et le gate onboarding (l.39-41) :

```ts
  // Opérateurs SYS (spec RBAC §2.bis) : leur seule surface est /admin.
  // AVANT le gate onboarding (un Staff SYS n'a pas d'onboarding) et AVANT
  // toute résolution d'org (un compte SYS est orgless — le self-heal de
  // getActiveOrgId ne doit jamais tourner pour lui).
  const sysRole = (session.user as { role?: string | null }).role;
  if (sysRole === "staff") redirect("/admin");
  if (isSuperAdmin(session.user.email)) {
    const [membership] = await db
      .select({ id: member.id })
      .from(member)
      .where(eq(member.userId, session.user.id))
      .limit(1);
    if (!membership) redirect("/admin");
  }
```

Imports à ajouter : `isSuperAdmin` depuis `@/lib/auth/super-admin`, `db` depuis `@/lib/db/client`, `member` depuis `@/lib/db/auth-schema`, `eq` depuis `drizzle-orm`. (`isCurrentUserSuperAdmin` est déjà importé — le laisser.)

- [ ] **Step 4: Page d'enrôlement 2FA hors groupes**

Créer `src/app/staff-2fa/page.tsx` :

```tsx
// src/app/staff-2fa/page.tsx — enrôlement 2FA des opérateurs SYS.
// Un Staff SYS ne peut pas atteindre /settings/account ((app) le redirige vers
// /admin) et (superadmin) est muré tant que le 2FA n'est pas actif : cette
// page hors groupes est sa porte d'enrôlement (spec §7).
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { TwoFactorSettings } from "@/components/two-factor-settings";
import { getSysOperatorSession } from "@/lib/auth/sys-permissions";

export default async function StaffTwoFactorPage() {
  const op = await getSysOperatorSession();
  if (!op) redirect("/login");
  if (op.twoFactorEnabled) redirect("/admin");
  const t = await getTranslations("SuperAdmin");
  return (
    <div className="mx-auto flex min-h-svh w-full max-w-lg flex-col justify-center gap-6 p-6">
      <div className="space-y-1">
        <h1 className="font-semibold text-xl">{t("staffTwoFaTitle")}</h1>
        <p className="text-muted-foreground text-sm">{t("staffTwoFaBody")}</p>
      </div>
      <TwoFactorSettings enabled={false} />
    </div>
  );
}
```

Ajouter dans les 4 catalogues, namespace `SuperAdmin` :
- en : `"staffTwoFaTitle": "Secure your account"`, `"staffTwoFaBody": "Two-factor authentication is required before you can access the management dashboard."`
- fr : `"staffTwoFaTitle": "Sécurisez votre compte"`, `"staffTwoFaBody": "La double authentification est obligatoire avant d'accéder au dash de management."`
- es : `"staffTwoFaTitle": "Protege tu cuenta"`, `"staffTwoFaBody": "La autenticación en dos pasos es obligatoria antes de acceder al panel de gestión."`
- de : `"staffTwoFaTitle": "Konto absichern"`, `"staffTwoFaBody": "Die Zwei-Faktor-Authentifizierung ist erforderlich, bevor Sie auf das Management-Dashboard zugreifen können."`

- [ ] **Step 5: Gate + commit**

Run: `bun run test:all` — Expected: PASS.

```bash
git add -A && git commit -m "feat(rbac): population SYS — exemption bootstrap d'org, routage (app)→/admin, page /staff-2fa"
```

---

### Task 7: `recordAdminAction` transactionnel (executor optionnel)

**Files:**
- Modify: `src/lib/admin/audit.ts` (l.58-79)
- Test: `src/lib/admin/audit.test.ts` (étendre)

**Interfaces:**
- Produces: `recordAdminAction(input: RecordAdminActionInput, executor?: AuditExecutor): Promise<void>` où `AuditExecutor = Pick<typeof db, "insert">`. Avec executor : insert via l'executor et **rethrow** (l'échec d'audit annule la transaction appelante — spec §8.9). Sans executor : comportement historique inchangé (fail-soft).

- [ ] **Step 1: Test rouge**

Dans `src/lib/admin/audit.test.ts`, ajouter :

```ts
test("executor path inserts via the executor and RETHROWS on failure", async () => {
  const inserted: unknown[] = [];
  const okExecutor = {
    insert: () => ({
      values: async (v: unknown) => {
        inserted.push(v);
      },
    }),
  };
  await recordAdminAction(
    { action: "x.y", targetType: "system" },
    okExecutor as never,
  );
  expect(inserted).toHaveLength(1);

  const failingExecutor = {
    insert: () => ({
      values: async () => {
        throw new Error("boom");
      },
    }),
  };
  await expect(
    recordAdminAction(
      { action: "x.y", targetType: "system" },
      failingExecutor as never,
    ),
  ).rejects.toThrow("boom");
});
```

(Adapter au harnais de mocks existant du fichier — il mocke déjà session/headers.) Run — Expected: FAIL (signature à 1 paramètre).

- [ ] **Step 2: Implémentation**

Refactorer `recordAdminAction` : extraire la construction de la ligne dans un helper interne `buildAuditRow(input, h, session)` puis :

```ts
export type AuditExecutor = Pick<typeof db, "insert">;

export async function recordAdminAction(
  input: RecordAdminActionInput,
  executor?: AuditExecutor,
): Promise<void> {
  // Path transactionnel (spec §8.9) : l'audit fait partie de la transaction de
  // la mutation — s'il échoue, TOUT rollback (jamais de mutation non tracée).
  if (executor) {
    const h = await headers();
    const session = await auth.api.getSession({ headers: h });
    await executor.insert(adminAuditLog).values(buildAuditRow(input, h, session));
    return;
  }
  // Path historique : fail-soft (l'audit ne doit pas casser une action non
  // transactionnelle déjà commise).
  try {
    const h = await headers();
    const session = await auth.api.getSession({ headers: h });
    await db.insert(adminAuditLog).values(buildAuditRow(input, h, session));
  } catch (err) {
    console.error("[audit] failed to record admin action", input.action, err);
  }
}
```

- [ ] **Step 3: Gate + commit**

Run: `bun run test:all` — Expected: PASS.

```bash
git add -A && git commit -m "feat(admin): recordAdminAction transactionnel via executor optionnel"
```

---

### Task 8: Refactor des actions users (`src/lib/admin/actions.ts`)

Remplace le gate binaire par `assertSysPermission`, réimplémente en Drizzle direct ce qui passait par `auth.api.*` (un Staff SYS n'est PAS un admin Better Auth), verrouille l'escalade de rôle. **Bonus structurel** : le filtre statut passe en SQL (il était en mémoire sur la page courante — bug connu).

**Files:**
- Modify: `src/lib/admin/actions.ts`
- Modify: `src/lib/admin/user-management/user-actions.test.ts`
- Modify: le composant client de la liste users s'il expose le filtre rôle (localiser via `grep -rn "AdminUserRoleFilter" src/components src/app`) — ajouter l'option `staff`.

**Interfaces:**
- Consumes: `assertSysPermission`, `getSysOperatorSession` (Task 5) ; `recordAdminAction(input, tx)` (Task 7).
- Produces: mêmes signatures publiques qu'avant SAUF : `AdminUserRoleFilter = "all" | "admin" | "staff" | "user"` ; `setUserRoleAction(userId: string, role: "user" | "admin")` conserve sa signature mais applique la règle anti-escalade ci-dessous.

**Mapping gate par action (spec §4)** — remplacer l'`assertSuperAdmin()`/`assertSuperAdminWith2FA()` d'ouverture par :

| Action | Gate |
|---|---|
| `listUsersAction`, `getUserDetailAction` | `assertSysPermission("users:read")` |
| `banUserAction`, `unbanUserAction` | `assertSysPermission("users:ban")` |
| `setUserRoleAction` | `assertSysPermission("users:role")` + règle anti-escalade |
| `revokeAllUserSessionsAction`, `revokeOneUserSessionAction` | `assertSysPermission("users:sessions")` |
| `resetUserTwoFactorAction` | `assertSysPermission("users:2fa-reset")` |
| `updateUserEmailAction`, `forceVerifyEmailAction` | `assertSysPermission("users:email")` |
| `impersonateUserAction`, `stopImpersonatingAction` | **INCHANGÉ** : `assertSuperAdminWith2FA()` + `auth.api.*` (Admin SYS only v1, spec §4) — ajouter un commentaire le disant |

- [ ] **Step 1: Réimplémenter `listUsersAction` en Drizzle**

Remplacer le corps (l.74-…) — le passage par `auth.api.listUsers` 403erait un Staff SYS, et le filtre statut en mémoire était faux :

```ts
export type AdminUserRoleFilter = "all" | "admin" | "staff" | "user";

export async function listUsersAction(
  input: ListUsersInput = {},
): Promise<ListUsersResult> {
  await assertSysPermission("users:read");
  const search = (input.search ?? "").trim();
  const offset = Math.max(0, input.offset ?? 0);
  const role: AdminUserRoleFilter = input.role ?? "all";
  const status: AdminUserStatusFilter = input.status ?? "all";

  // Drizzle direct (pas auth.api.listUsers : un Staff SYS n'est pas un admin
  // Better Auth — spec §5). Le filtre statut passe en SQL : l'ancien filtre
  // en mémoire ne filtrait que la page courante.
  const conds = [];
  if (search) conds.push(ilike(user.email, `%${search}%`));
  if (role === "admin") conds.push(eq(user.role, "admin"));
  if (role === "staff") conds.push(eq(user.role, "staff"));
  if (role === "user")
    conds.push(or(isNull(user.role), eq(user.role, "user")));
  if (status === "active")
    conds.push(or(isNull(user.banned), eq(user.banned, false)));
  if (status === "banned") conds.push(eq(user.banned, true));
  const where = conds.length ? and(...conds) : undefined;

  const [rows, [{ total }]] = await Promise.all([
    db
      .select({
        id: user.id,
        name: user.name,
        email: user.email,
        role: user.role,
        banned: user.banned,
        createdAt: user.createdAt,
      })
      .from(user)
      .where(where)
      .orderBy(desc(user.createdAt))
      .limit(PAGE_SIZE)
      .offset(offset),
    db.select({ total: count() }).from(user).where(where),
  ]);

  return {
    users: rows.map((u) => ({ ...u, banned: u.banned ?? false })),
    total,
    limit: PAGE_SIZE,
    offset,
    role,
    status,
  };
}
```

Imports drizzle à compléter : `count, desc, ilike, isNull, or` (vérifier les noms de colonnes `banned`/`banReason`/`banExpires` dans `src/lib/db/auth-schema.ts` — plugin admin).

- [ ] **Step 2: `banUserAction`/`unbanUserAction` en Drizzle + tx + audit**

Modèle exact pour `banUserAction` (remplace l'appel `auth.api.banUser` l.187-194 et l'audit l.195-204) :

```ts
  const banExpires = durationSeconds
    ? new Date(Date.now() + durationSeconds * 1000)
    : null;
  // Sémantique du plugin admin reproduite en direct : flag + expiry + toutes
  // les sessions révoquées. Mutation + audit dans UNE transaction (spec §8.9).
  await db.transaction(async (tx) => {
    await tx
      .update(user)
      .set({ banned: true, banReason: reason ?? null, banExpires })
      .where(eq(user.id, id));
    await tx.delete(sessionTable).where(eq(sessionTable.userId, id));
    await recordAdminAction(
      {
        action: "user.ban",
        targetType: "user",
        targetId: id,
        targetLabel: target.email,
        metadata: { reason: reason ?? null, durationSeconds: durationSeconds ?? null },
      },
      tx,
    );
  });
```

`unbanUserAction` : même structure, `set({ banned: false, banReason: null, banExpires: null })`, pas de purge de sessions, action `"user.unban"`.

- [ ] **Step 3: `setUserRoleAction` — anti-escalade**

```ts
export async function setUserRoleAction(
  userId: string,
  role: "user" | "admin",
): Promise<void> {
  await assertSysPermission("users:role");
  const id = idSchema.parse(userId);
  const target = await loadTargetUser(id);
  assertMutableTarget(target);
  // Anti-escalade (spec §8) : TOUT ce qui touche le niveau admin/staff — le
  // donner OU le retirer — reste Admin SYS. Sinon un Staff SYS avec users:role
  // promouvrait un complice (qui hériterait des endpoints Better Auth admin).
  if (role === "admin" || target.role === "admin" || target.role === "staff") {
    await assertSuperAdminWith2FA();
  }
  await db.transaction(async (tx) => {
    await tx.update(user).set({ role }).where(eq(user.id, id));
    await recordAdminAction(
      {
        action: "user.set_role",
        targetType: "user",
        targetId: id,
        targetLabel: target.email,
        metadata: { role, previousRole: target.role ?? null },
      },
      tx,
    );
  });
  revalidateUserPaths(id);
}
```

- [ ] **Step 4: Les autres actions**

- `revokeAllUserSessionsAction` / `revokeOneUserSessionAction` : si elles passaient par `auth.api.revokeUserSessions`, réimplémenter : `tx.delete(sessionTable).where(eq(sessionTable.userId, id))` (all) / `.where(eq(sessionTable.token, sessionToken))` (one) + audit executor.
- `resetUserTwoFactorAction`, `updateUserEmailAction`, `forceVerifyEmailAction` : déjà en Drizzle (cf. mocks du test existant) — swap du gate + envelopper mutation+audit dans `db.transaction` avec `recordAdminAction(…, tx)`.
- `impersonateUserAction`/`stopImpersonatingAction` : NE PAS toucher au corps ; ajouter le commentaire « Admin SYS only v1 (spec §4) — l'impersonation hérite de tous les droits org de la cible ».
- `getUserDetailAction` : swap gate → `users:read`.

- [ ] **Step 5: Mettre à jour les tests**

`src/lib/admin/user-management/user-actions.test.ts` : remplacer les mocks de `auth.api.listUsers`/`banUser` par les chemins Drizzle (le harnais mocke déjà `db`/tx — recon : `update`, `delete` comptés). Ajouter : (a) un cas anti-escalade — `setUserRoleAction(x, "admin")` avec un opérateur staff-sys mocké → rejette ; (b) `listUsersAction` avec `status: "banned"` construit bien un `where` SQL (pas de filtre en mémoire).

- [ ] **Step 6: Option de filtre `staff` dans l'UI users**

`grep -rn "AdminUserRoleFilter\|role === \"admin\"" src/components/admin src/app/\(superadmin\)/admin/users` → dans le composant de filtres, ajouter l'option `staff` au sélecteur de rôle (miroir des options existantes `admin`/`user`).

- [ ] **Step 7: Gate + commit**

Run: `bunx biome check src/lib/admin/ && bun run test:all` — Expected: PASS.

```bash
git add -A && git commit -m "refactor(admin): actions users sur assertSysPermission + Drizzle direct + anti-escalade de rôle"
```

---

### Task 9: Refactor des gates orgs / billing / export / audit

**Files:**
- Modify: `src/lib/admin/org-actions.ts`
- Modify: `src/lib/admin/org-queries.ts`
- Modify: `src/lib/admin/billing-actions.ts`
- Modify: `src/lib/admin/billing-queries.ts`
- Modify: `src/lib/admin/export-queries.ts` + `src/app/(superadmin)/admin/export/[dataset]/route.ts`
- Modify: `src/lib/admin/audit.ts` (`listAuditLog`)
- Tests: `src/lib/admin/org-management/org-actions.test.ts`, `src/lib/admin/audit.test.ts` (adapter les mocks de gate)

**Interfaces:**
- Consumes: `assertSysPermission` (Task 5), `recordAdminAction(input, tx)` (Task 7).
- Produces: signatures publiques inchangées.

- [ ] **Step 1: Swap des gates selon le mapping (spec §4)**

| Fonction | Gate |
|---|---|
| `listOrgsAction`, `getOrgDetailAction` | `assertSysPermission("orgs:read")` |
| `addMemberAction`, `removeMemberAction`, `setMemberRoleAction`, `transferOrgOwnershipAction` | `assertSysPermission("orgs:members")` |
| `setOrgLimitOverrideAction`, `clearOrgLimitOverrideAction` | `assertSysPermission("orgs:limits")` |
| `listBillingOverviewAction`, `getOrgBillingDetailAction` | `assertSysPermission("billing:read")` |
| `resyncOrgSubscriptionAction`, `extendOrgTrialAction`, `applyCouponAction`, `grantComplimentaryAction` | `assertSysPermission("billing:manage")` |
| `listAuditLog` | `assertSysPermission("audit:read")` |
| export (page + route `[dataset]`) | `assertSysPermission("export:run")` |

Les org-actions sont déjà en Drizzle direct (pas d'`auth.api.*` — vérifié) : c'est un swap de gate. Pour chaque action **mutante** de cette liste, envelopper mutation + `recordAdminAction(…, tx)` dans `db.transaction` (même modèle que Task 8 Step 2 — ex. pour `addMemberAction` : l'insert `member` et l'audit `org.add_member` partagent la transaction).

- [ ] **Step 2: Adapter les tests**

Les tests org-actions mockent le gate super-admin : les faire mocker `@/lib/auth/sys-permissions` (`assertSysPermission: mock(async () => {})`) et vérifier qu'au moins UNE action de chaque groupe appelle le gate avec la bonne permission (spy sur l'argument).

- [ ] **Step 3: Gate + commit**

Run: `bun run test:all` — Expected: PASS.
Run: `grep -rn "assertSuperAdmin" src/lib/admin src/app/\(superadmin\) | grep -v impersonat | grep -v "setUserRoleAction" | grep -v test | grep -v staff`
Expected: AUCUN résultat (seuls l'impersonation et l'anti-escalade de Task 8 gardent le gate super-admin).

```bash
git add -A && git commit -m "refactor(admin): gates orgs/billing/export/audit sur assertSysPermission"
```

---

### Task 10: Layout `(superadmin)` + nav par permissions

**Files:**
- Modify: `src/app/(superadmin)/layout.tsx`
- Modify: `src/components/admin/admin-sidebar.tsx`
- Modify: `messages/{en,fr,es,de}.json` (clé `SuperAdmin.navStaff`)

**Interfaces:**
- Consumes: `getSysOperatorSession`, `currentSysPermissions`, `SysPermission` (Task 5).
- Produces: `AdminSidebar({ permissions }: { permissions: SysPermission[] })` — la prop remplace le rendu inconditionnel.

- [ ] **Step 1: Layout**

Remplacer le gate du layout (garde le shell/i18n existants) :

```tsx
import {
  currentSysPermissions,
  getSysOperatorSession,
} from "@/lib/auth/sys-permissions";
// (supprimer les imports auth/headers/isSuperAdmin devenus inutiles)

export default async function SuperAdminLayout({ children }: Readonly<{ children: React.ReactNode }>) {
  const op = await getSysOperatorSession();
  const t = await getTranslations("SuperAdmin");

  if (!op) {
    // Pas de session → login ; session non-opérateur → 404 (ne jamais révéler
    // la surface). getSysOperatorSession ne distingue pas les deux : re-check.
    redirect("/login"); // voir note ci-dessous
  }
  ...
}
```

⚠️ Note de fidélité : l'ancien gate distinguait « pas de session » (→ `/login`) de « pas super-admin » (→ `notFound()`). Conserver cette distinction : appeler d'abord `auth.api.getSession` comme avant, puis `if (!session?.user) redirect("/login");` puis remplacer `if (!isSuperAdmin(session.user.email)) notFound();` par :

```tsx
  const role = (session.user as { role?: string | null }).role;
  const isOperator = isSuperAdmin(session.user.email) || role === "staff";
  if (!isOperator) notFound();
```

Pour l'interstitiel 2FA (branche `!twoFactorEnabled` existante) : le CTA pointe vers `/staff-2fa` pour TOUS les opérateurs —

```tsx
<Link href="/staff-2fa">{t("twoFaSetupCta")}</Link>
```

Raison (finding review Task 6) : un super-admin ORGLESS sans 2FA renvoyé vers `/settings/account` serait rebondi par le redirect SYS du layout `(app)` (pas de membership → `/admin`) → boucle infinie `/admin` ⇄ `/settings/account`. `/staff-2fa` fonctionne pour les deux populations (gate `getSysOperatorSession`, composant identique) — la classe de bug disparaît.

Et le lien « exitToApp » n'a pas de sens pour un staff orgless : le masquer quand `role === "staff"` (dans l'interstitiel ET le header du shell).

En bas du layout, résoudre les permissions et les passer au sidebar :

```tsx
  const permissions = [...(await currentSysPermissions())];
  ...
  <AdminSidebar permissions={permissions} />
```

- [ ] **Step 2: Sidebar filtré**

`src/components/admin/admin-sidebar.tsx` — annoter `NAV` et filtrer :

```tsx
import type { SysPermission } from "@/lib/auth/sys-permissions";

const NAV = [
  { key: "navOverview", href: "/admin", icon: LayoutDashboardIcon, perm: null },
  { key: "navUsers", href: "/admin/users", icon: UsersIcon, perm: "users:read" },
  { key: "navOrgs", href: "/admin/orgs", icon: Building2Icon, perm: "orgs:read" },
  { key: "navBilling", href: "/admin/billing", icon: CreditCardIcon, perm: "billing:read" },
  { key: "navAudit", href: "/admin/audit", icon: ScrollTextIcon, perm: "audit:read" },
  { key: "navExport", href: "/admin/export", icon: DownloadIcon, perm: "export:run" },
  { key: "navStaff", href: "/admin/staff", icon: ShieldCheckIcon, perm: "staff:manage" },
] as const satisfies readonly {
  key: string;
  href: string;
  icon: unknown;
  perm: SysPermission | null;
}[];

export function AdminSidebar({ permissions }: { permissions: SysPermission[] }) {
  const allowed = new Set(permissions);
  const items = NAV.filter((i) => i.perm === null || allowed.has(i.perm));
  ...
}
```

(`ShieldCheckIcon` depuis lucide-react ; le rendu itère `items` au lieu de `NAV`.)

i18n `SuperAdmin.navStaff` : en `"Staff"`, fr `"Staff"`, es `"Equipo"`, de `"Team"`.

- [ ] **Step 3: Gate + commit**

Run: `bun run test:all` — Expected: PASS.

```bash
git add -A && git commit -m "feat(admin): layout ouvert aux opérateurs SYS + nav filtrée par permissions"
```

---

### Task 11: Lib de gestion des Staff SYS (core + actions)

**Files:**
- Create: `src/lib/admin/staff-management/core.ts` (fonctions DB pures, injectables — testables)
- Create: `src/lib/admin/staff-management/core.test.ts` (tests DB, base test)
- Create: `src/lib/admin/staff-actions.ts` (server actions, gate `staff:manage`)

**Interfaces:**
- Consumes: `staffPermission` (Task 4), `isGrantableSysPermission`/`GRANTABLE_SYS_PERMISSIONS` (Task 5), `recordAdminAction(input, tx)` (Task 7), tables `user`/`member`/`session` (`@/lib/db/auth-schema`).
- Produces (consommé par la Task 12) :
  - `listStaff(db): Promise<StaffRow[]>` avec `StaffRow = { id: string; name: string; email: string; twoFactorEnabled: boolean; permissions: string[] }`
  - `promoteToStaff(db, actorId, email): Promise<{ ok: true; userId: string } | { ok: false; error: string }>`
  - `setStaffPermissions(db, actorId, userId, perms): Promise<{ ok: true } | { ok: false; error: string }>`
  - `removeStaff(db, actorId, userId): Promise<{ ok: true } | { ok: false; error: string }>`
  - Server actions miroirs : `listStaffAction()`, `createStaffAction(input)`, `promoteToStaffAction(email)`, `setStaffPermissionsAction(userId, perms)`, `removeStaffAction(userId)`.

- [ ] **Step 1: Tests DB rouges**

`core.test.ts` (pattern des tests DB existants — `DB_TESTS=1`, seeds directs, garde `assertTestDb` si le harnais du repo l'utilise) :

```ts
import { beforeEach, describe, expect, test } from "bun:test";
import { eq } from "drizzle-orm";
import { staffPermission } from "@/lib/db/admin-schema";
import { member, organization, session, user } from "@/lib/db/auth-schema";
import { db } from "@/lib/db/client";
import { promoteToStaff, removeStaff, setStaffPermissions } from "./core";

const ACTOR = "rbac-test-actor";
const TARGET = "rbac-test-target";

beforeEach(async () => {
  await db.delete(staffPermission);
  await db.delete(member).where(eq(member.userId, TARGET));
  await db.delete(user).where(eq(user.id, TARGET));
  await db.delete(user).where(eq(user.id, ACTOR));
  await db.insert(user).values([
    { id: ACTOR, name: "Actor", email: "actor@rbac.test" },
    { id: TARGET, name: "Target", email: "target@rbac.test" },
  ]);
});

describe("promoteToStaff", () => {
  test("promotes an orgless verified user to role staff", async () => {
    const res = await promoteToStaff(db, ACTOR, "target@rbac.test");
    expect(res.ok).toBe(true);
    const [u] = await db.select({ role: user.role }).from(user).where(eq(user.id, TARGET));
    expect(u.role).toBe("staff");
  });
  test("refuses a user with an org membership (population SYS orgless — spec §2.bis)", async () => {
    await db.insert(organization).values({ id: "rbac-test-org", name: "o", slug: "rbac-test-o", createdAt: new Date() });
    await db.insert(member).values({ id: "rbac-test-m", organizationId: "rbac-test-org", userId: TARGET, role: "owner", createdAt: new Date() });
    const res = await promoteToStaff(db, ACTOR, "target@rbac.test");
    expect(res.ok).toBe(false);
  });
});

describe("setStaffPermissions", () => {
  test("replaces the grant set; rejects reserved and unknown permissions", async () => {
    await promoteToStaff(db, ACTOR, "target@rbac.test");
    const ok = await setStaffPermissions(db, ACTOR, TARGET, ["users:read", "orgs:read"]);
    expect(ok.ok).toBe(true);
    const rows = await db.select().from(staffPermission).where(eq(staffPermission.userId, TARGET));
    expect(rows.map((r) => r.permission).sort()).toEqual(["orgs:read", "users:read"]);

    const bad = await setStaffPermissions(db, ACTOR, TARGET, ["staff:manage"]);
    expect(bad.ok).toBe(false);
    const badUnknown = await setStaffPermissions(db, ACTOR, TARGET, ["nope:nope"]);
    expect(badUnknown.ok).toBe(false);
  });
});

describe("removeStaff", () => {
  test("transactional: role back to user, grants purged, sessions revoked", async () => {
    await promoteToStaff(db, ACTOR, "target@rbac.test");
    await setStaffPermissions(db, ACTOR, TARGET, ["users:read"]);
    await db.insert(session).values({ id: "rbac-test-s", token: "rbac-test-tok", userId: TARGET, expiresAt: new Date(Date.now() + 3600_000), createdAt: new Date(), updatedAt: new Date() });
    const res = await removeStaff(db, ACTOR, TARGET);
    expect(res.ok).toBe(true);
    const [u] = await db.select({ role: user.role }).from(user).where(eq(user.id, TARGET));
    expect(u.role).toBe("user");
    expect(await db.select().from(staffPermission).where(eq(staffPermission.userId, TARGET))).toHaveLength(0);
    expect(await db.select().from(session).where(eq(session.userId, TARGET))).toHaveLength(0);
  });
});
```

(Adapter les colonnes NOT NULL exactes de `session`/`organization` à `auth-schema.ts` si l'insert échoue.) Run: `bun run test src/lib/admin/staff-management/core.test.ts` — Expected: FAIL (module absent).

- [ ] **Step 2: `core.ts`**

```ts
// src/lib/admin/staff-management/core.ts — mutations du plan SYS (spec §7).
// Fonctions DB injectables (testables sans session) ; les server actions de
// staff-actions.ts les enveloppent du gate staff:manage + de l'audit.
import "server-only";
import { eq, sql } from "drizzle-orm";
import { isGrantableSysPermission } from "@/lib/auth/sys-permissions";
import { staffPermission } from "@/lib/db/admin-schema";
import { member, session, user } from "@/lib/db/auth-schema";
import type { db as DbClient } from "@/lib/db/client";

type Db = typeof DbClient;

export interface StaffRow {
  id: string;
  name: string;
  email: string;
  twoFactorEnabled: boolean;
  permissions: string[];
}

export async function listStaff(db: Db): Promise<StaffRow[]> {
  const users = await db
    .select({
      id: user.id,
      name: user.name,
      email: user.email,
      twoFactorEnabled: user.twoFactorEnabled,
    })
    .from(user)
    .where(eq(user.role, "staff"));
  const grants = await db.select().from(staffPermission);
  const byUser = new Map<string, string[]>();
  for (const g of grants) {
    const list = byUser.get(g.userId) ?? [];
    list.push(g.permission);
    byUser.set(g.userId, list);
  }
  return users.map((u) => ({
    ...u,
    twoFactorEnabled: Boolean(u.twoFactorEnabled),
    permissions: (byUser.get(u.id) ?? []).sort(),
  }));
}

export async function promoteToStaff(
  db: Db,
  _actorId: string,
  email: string,
): Promise<{ ok: true; userId: string } | { ok: false; error: string }> {
  const [u] = await db
    .select({ id: user.id, role: user.role, emailVerified: user.emailVerified })
    .from(user)
    .where(eq(sql`lower(${user.email})`, email.trim().toLowerCase()))
    .limit(1);
  if (!u) return { ok: false, error: "Aucun utilisateur avec cet email" };
  if (u.role === "staff") return { ok: false, error: "Déjà staff" };
  if (u.role === "admin")
    return { ok: false, error: "Compte admin système — non promouvable" };
  // Population SYS orgless par construction (spec §2.bis).
  const [m] = await db
    .select({ id: member.id })
    .from(member)
    .where(eq(member.userId, u.id))
    .limit(1);
  if (m)
    return {
      ok: false,
      error: "Ce compte appartient à une organisation — les opérateurs SYS sont orgless",
    };
  await db.update(user).set({ role: "staff" }).where(eq(user.id, u.id));
  return { ok: true, userId: u.id };
}

export async function setStaffPermissions(
  db: Db,
  actorId: string,
  userId: string,
  perms: string[],
): Promise<{ ok: true } | { ok: false; error: string }> {
  const invalid = perms.filter((p) => !isGrantableSysPermission(p));
  if (invalid.length)
    return { ok: false, error: `Permissions non assignables: ${invalid.join(", ")}` };
  const [u] = await db
    .select({ role: user.role })
    .from(user)
    .where(eq(user.id, userId))
    .limit(1);
  if (u?.role !== "staff") return { ok: false, error: "La cible n'est pas un staff" };
  await db.transaction(async (tx) => {
    await tx.delete(staffPermission).where(eq(staffPermission.userId, userId));
    if (perms.length) {
      await tx.insert(staffPermission).values(
        [...new Set(perms)].map((permission) => ({
          userId,
          permission,
          grantedBy: actorId,
        })),
      );
    }
  });
  return { ok: true };
}

export async function removeStaff(
  db: Db,
  _actorId: string,
  userId: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
  const [u] = await db
    .select({ role: user.role })
    .from(user)
    .where(eq(user.id, userId))
    .limit(1);
  if (u?.role !== "staff") return { ok: false, error: "La cible n'est pas un staff" };
  // Transactionnel (spec §7) : pas d'état fantôme — rôle, grants et sessions
  // retombent ensemble.
  await db.transaction(async (tx) => {
    await tx.update(user).set({ role: "user" }).where(eq(user.id, userId));
    await tx.delete(staffPermission).where(eq(staffPermission.userId, userId));
    await tx.delete(session).where(eq(session.userId, userId));
  });
  return { ok: true };
}
```

(Si `user.twoFactorEnabled` n'existe pas sous ce nom dans `auth-schema.ts`, vérifier la colonne du plugin twoFactor — `twoFactorEnabled` — et adapter.)

Run: `bun run test src/lib/admin/staff-management/core.test.ts` — Expected: PASS.

- [ ] **Step 3: Server actions**

Créer `src/lib/admin/staff-actions.ts` :

```ts
// src/lib/admin/staff-actions.ts — gestion des opérateurs Staff SYS (spec §7).
// Gate : staff:manage (jamais délégable ⇒ Admin SYS de fait).
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { z } from "zod";
import { auth } from "@/lib/auth";
import {
  assertSysPermission,
  getSysOperatorSession,
} from "@/lib/auth/sys-permissions";
import { db } from "@/lib/db/client";
import { recordAdminAction } from "./audit";
import {
  listStaff,
  promoteToStaff,
  removeStaff,
  setStaffPermissions,
  type StaffRow,
} from "./staff-management/core";

export type { StaffRow };

const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
const createStaffSchema = z.object({
  name: z.string().trim().min(1).max(120),
  email: emailSchema,
  password: z.string().min(12).max(128),
});

export async function listStaffAction(): Promise<StaffRow[]> {
  await assertSysPermission("staff:manage");
  return listStaff(db);
}

/** Crée un compte Staff SYS (voie normale — jamais le signup public).
 *  auth.api.createUser exige un admin Better Auth : l'Admin SYS a role:"admin"
 *  (bridge SUPER_ADMIN_EMAILS), donc l'appel passe. Le hook user.create.after
 *  saute le bootstrap d'org pour role "staff" (Task 6). */
export async function createStaffAction(input: {
  name: string;
  email: string;
  password: string;
}): Promise<{ ok: true } | { ok: false; error: string }> {
  await assertSysPermission("staff:manage");
  const parsed = createStaffSchema.safeParse(input);
  if (!parsed.success)
    return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalide" };
  const { name, email, password } = parsed.data;
  await auth.api.createUser({
    headers: await headers(),
    body: { name, email, password, role: "staff" },
  });
  await recordAdminAction({
    action: "staff.create",
    targetType: "user",
    targetLabel: email,
    metadata: { name },
  });
  revalidatePath("/admin/staff");
  return { ok: true };
}

export async function promoteToStaffAction(
  email: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
  await assertSysPermission("staff:manage");
  const op = await getSysOperatorSession();
  const res = await promoteToStaff(db, op?.userId ?? "", emailSchema.parse(email));
  if (res.ok) {
    await recordAdminAction({
      action: "staff.promote",
      targetType: "user",
      targetId: res.userId,
      targetLabel: email,
    });
    revalidatePath("/admin/staff");
    return { ok: true };
  }
  return res;
}

export async function setStaffPermissionsAction(
  userId: string,
  perms: string[],
): Promise<{ ok: true } | { ok: false; error: string }> {
  await assertSysPermission("staff:manage");
  const op = await getSysOperatorSession();
  const res = await setStaffPermissions(db, op?.userId ?? "", userId, perms);
  if (res.ok) {
    await recordAdminAction({
      action: "staff.set_permissions",
      targetType: "user",
      targetId: userId,
      metadata: { permissions: [...perms].sort() },
    });
    revalidatePath("/admin/staff");
  }
  return res;
}

export async function removeStaffAction(
  userId: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
  await assertSysPermission("staff:manage");
  const op = await getSysOperatorSession();
  const res = await removeStaff(db, op?.userId ?? "", userId);
  if (res.ok) {
    await recordAdminAction({
      action: "staff.remove",
      targetType: "user",
      targetId: userId,
    });
    revalidatePath("/admin/staff");
  }
  return res;
}
```

- [ ] **Step 4: Gate + commit**

Run: `bun run test:all` — Expected: PASS.

```bash
git add -A && git commit -m "feat(admin): gestion des Staff SYS — core injectable + server actions gatées staff:manage"
```

---

### Task 12: UI `/admin/staff`

**Files:**
- Create: `src/app/(superadmin)/admin/staff/page.tsx`
- Create: `src/components/admin/staff-manager.tsx`
- Modify: `messages/{en,fr,es,de}.json` (namespace `SuperAdmin`, clés listées)

**Interfaces:**
- Consumes: `listStaffAction`, `createStaffAction`, `promoteToStaffAction`, `setStaffPermissionsAction`, `removeStaffAction`, `StaffRow` (Task 11) ; `SYS_PERMISSION_GROUPS` (Task 5).
- Produces: page server + composant client. Suivre le style des composants admin existants (`org-detail.tsx` : Table/Dialog/Select shadcn, chrome zinc/red).

- [ ] **Step 1: Page**

```tsx
// src/app/(superadmin)/admin/staff/page.tsx — gestion des opérateurs Staff SYS.
// Le layout (superadmin) a déjà validé l'opérateur ; on re-gate staff:manage
// ici (défense en profondeur — un staff sans ce droit prend un 404).
import { notFound } from "next/navigation";
import { StaffManager } from "@/components/admin/staff-manager";
import { currentSysPermissions } from "@/lib/auth/sys-permissions";
import { listStaffAction } from "@/lib/admin/staff-actions";

export default async function AdminStaffPage() {
  const perms = await currentSysPermissions();
  if (!perms.has("staff:manage")) notFound();
  const staff = await listStaffAction();
  return <StaffManager initialStaff={staff} />;
}
```

- [ ] **Step 2: Composant client**

`src/components/admin/staff-manager.tsx` — `"use client"`. Contenu requis (styler comme `org-detail.tsx`) :
1. **Tableau** des staff : nom, email, badge 2FA (`twoFactorEnabled`), nombre de permissions, actions (Éditer la grille / Retirer).
2. **Dialog « Créer un staff »** : champs nom / email / mot de passe → `createStaffAction` ; toast d'erreur sinon (sonner, comme le reste de l'admin).
3. **Dialog « Promouvoir »** : champ email → `promoteToStaffAction` ; affiche l'erreur retournée (ex. compte avec org → message explicite).
4. **Dialog « Grille de permissions »** : checkboxes groupées par ressource en itérant `SYS_PERMISSION_GROUPS` (import depuis `@/lib/auth/sys-permissions` — module server-only ? NON : les constantes doivent être importables côté client. Si `server-only` bloque l'import, extraire les constantes/types dans `src/lib/auth/sys-permissions-const.ts` SANS `server-only`, ré-exportées par `sys-permissions.ts`, et importer la version const ici). Ni `staff:manage` ni `users:impersonate` ne figurent dans la grille (elles ne sont pas dans les groupes — garanti par construction). Bouton Enregistrer → `setStaffPermissionsAction(userId, selected)`.
5. **Retrait** : bouton avec confirmation (Dialog) → `removeStaffAction`.
6. État local : `useState(initialStaff)` + rechargement via le retour des actions (`router.refresh()` après succès).

Clés i18n (namespace `SuperAdmin`) à ajouter aux 4 catalogues : `staffTitle` ("Staff SYS" / idem fr / "Equipo SYS" / "SYS-Team"), `staffCreate` ("Create staff" / "Créer un staff" / "Crear staff" / "Staff anlegen"), `staffPromote` ("Promote a user" / "Promouvoir un utilisateur" / "Promover usuario" / "Benutzer befördern"), `staffRemove` ("Remove" / "Retirer" / "Quitar" / "Entfernen"), `staffPermissions` ("Permissions" ×4), `staffNoTwoFa` ("2FA missing" / "2FA absent" / "Sin 2FA" / "Ohne 2FA"), `staffEmpty` ("No staff yet" / "Aucun staff" / "Sin staff" / "Noch kein Staff").

- [ ] **Step 3: Vérification manuelle ciblée**

Run: `bun run test:all` puis démarrer le dev server et vérifier `/admin/staff` en tant qu'elevaseo99 (Admin SYS) : la page rend, la création d'un staff de test fonctionne, la grille coche/décoche, le retrait purge. (Si la vérif navigateur est indisponible, vérifier au moins le rendu RSC via curl authentifié et le noter dans le rapport.)

- [ ] **Step 4: Commit**

```bash
git add -A && git commit -m "feat(admin): UI /admin/staff — création, promotion, grille de permissions, retrait"
```

---

### Task 13: Gate final, garde-fous grep, docs

**Files:**
- Modify: `docs/project/roadmap.md` (entrée ✅ RBAC deux plans)
- Modify: `docs/superpowers/specs/2026-07-09-rbac-roles-staff-permissions-design.md` (statut → implémenté)

- [ ] **Step 1: Garde-fous grep (spec §9)**

```bash
# 1. Aucun gate super-admin résiduel dans les actions admin (hors exceptions volontaires) :
grep -rn "assertSuperAdmin" src/lib/admin src/app/\(superadmin\) | grep -vE "impersonat|setUserRoleAction|anti-escalade|test"
# Expected: vide.
# 2. Le rôle d'org "staff" n'existe plus :
grep -rn '"staff"' src --include='*.ts' --include='*.tsx' | grep -vE "support|senderType|username-policy|user\.role|role.*staff-sys|sys-permissions|staff-management|staff-actions|staff-manager|staff-2fa|org-bootstrap|sys-account|layout|actions"
# Passer en revue CHAQUE hit restant : tout hit doit être du sens SYS (user.role), sinon corriger.
# 3. adminRoles inchangé :
grep -n 'adminRoles' src/lib/auth.ts
# Expected: adminRoles: ["admin"] — PAS de "staff".
```

- [ ] **Step 2: Suite complète + build**

Run: `bun run test:all` — Expected: PASS intégral.
Run: `bun run build` — Expected: build Next OK (attrape les erreurs de types RSC/client que les tests ne voient pas).

- [ ] **Step 3: Migration dev + smoke**

Run: `bun run db:migrate` (base dev — applique 0061/0062).
Smoke : dev server, login elevaseo99 → `/admin` OK, `/admin/staff` OK ; un user org normal → `/admin` = 404 ; les pages `(app)` fonctionnent (org eleva-seo).

- [ ] **Step 4: Docs + commit final**

`docs/project/roadmap.md` : entrée ✅ « RBAC deux plans — rôles org corrigés (member/billing/GSC), Staff SYS à permissions atomiques, population SYS dissociée » avec renvoi à la spec. Marquer la spec « implémentée (feature/rbac-two-planes) ».

```bash
git add -A && git commit -m "docs(rbac): roadmap + spec marquées implémentées, garde-fous validés"
```

**Fin de branche** (par l'orchestrateur, PAS par un subagent) : merge `--ff-only` dans `dev`, suppression de la branche. **Jamais `main`.**
