# Correctifs des culs-de-sac du parcours d'entrée — 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:** Corriger les quatre culs-de-sac du parcours d'entrée qui bloquent ou trompent l'utilisateur aujourd'hui, indépendamment de la refonte du wizard.

**Architecture:** Aucune refonte. Chaque correctif extrait la décision dans un module pur testable (convention du repo : pas de test de rendu React), puis branche ce module sur le composant existant. Trois des quatre correctifs sont couverts par des tests ; le quatrième est purement présentationnel et se vérifie visuellement.

**Tech Stack:** Next 16 App Router, React 19, Better Auth (+ plugin Stripe), Drizzle/Postgres, next-intl, shadcn/ui, bun, biome.

**Branche :** `fix/onboarding-dead-ends`, créée depuis `dev`. Ne PAS travailler sur `feature/onboarding-wizard-tutorial`.

## Global Constraints

- **Package manager : bun.** Jamais `npm`/`npx`. La CLI shadcn s'invoque avec `bunx --bun shadcn@latest add <composant>` (`npx` échoue en `EOVERRIDE` sur ce repo, c'est une limite npm, pas un défaut du projet).
- **Aucun import direct de `radix-ui` dans le nouveau code.** Tout passe par les composants de `src/components/ui/`.
- **i18n : toute clé ajoutée doit exister dans `messages/en.json` ET `messages/fr.json`.** `src/i18n/catalogs.test.ts` échoue sinon. Seules ces deux locales existent.
- **Tests DB** : `describe.skipIf(!RUN_DB)` avec `const RUN_DB = process.env.DB_TESTS === "1"`, `mock.module("server-only", () => ({}))` en tête, imports du `db`/schémas **dynamiques dans `beforeEach`**, `await assertTestDb()` **avant** tout `delete`, et jamais de `db.end()` en teardown.
- **Server Actions** : un module `'use server'` n'exporte que des wrappers authentifiés ; toute fonction privilégiée vit dans un module sans `'use server'`. Un test de surface (`Object.keys(mod).sort()`) verrouille la liste des exports.
- **Style des retours d'action** : union discriminée `{ ok: true, ... } | { ok: false, error: ... }`, pas de `throw` pour les erreurs métier.
- **Conventional commits** obligatoires. Jamais de commit sur `main`.
- **Lancer un test isolé** : `bun run test <chemin>`. Suite complète : `bun run test:all`. La base de test doit être migrée : `bun run db:migrate:test`.

---

### Task 1 : Signup — état explicite quand la vérification email est désactivée

Aujourd'hui, quand `env.emailEnabled` est faux (dev local sans Resend, self-host sans provider), un signup réussi ne produit **rien** : le bouton se réactive, aucun message, aucune redirection. Le compte est pourtant créé et la session posée. La cause est un `else` manquant dans `submit()`.

**Files:**
- Create: `src/lib/auth/post-signup.ts`
- Create: `src/lib/auth/post-signup.test.ts`
- Modify: `src/components/signup-form.tsx` (branche de fin de `submit()`, ~ lignes 176-192)

**Interfaces:**
- Consumes: rien.
- Produces: `postSignupOutcome(input: PostSignupInput): PostSignupOutcome` — consommé uniquement par `signup-form.tsx`.

- [ ] **Step 1: Écrire le test qui échoue**

Créer `src/lib/auth/post-signup.test.ts` :

```ts
import { describe, expect, it } from "bun:test";
import { postSignupOutcome } from "./post-signup";

describe("postSignupOutcome", () => {
  it("redirige un utilisateur invité (déjà vérifié) même si la vérification est requise", () => {
    expect(
      postSignupOutcome({ emailVerified: true, emailVerificationRequired: true }),
    ).toEqual({ kind: "redirect" });
  });

  it("affiche l'écran de vérification quand elle est requise et l'email non vérifié", () => {
    expect(
      postSignupOutcome({ emailVerified: false, emailVerificationRequired: true }),
    ).toEqual({ kind: "verification-sent" });
  });

  it("redirige quand la vérification est désactivée : la session est déjà posée", () => {
    expect(
      postSignupOutcome({ emailVerified: false, emailVerificationRequired: false }),
    ).toEqual({ kind: "redirect" });
  });

  it("redirige quand la vérification est désactivée et l'email déjà vérifié", () => {
    expect(
      postSignupOutcome({ emailVerified: true, emailVerificationRequired: false }),
    ).toEqual({ kind: "redirect" });
  });
});
```

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/auth/post-signup.test.ts`
Expected: FAIL — `Cannot find module './post-signup'`.

- [ ] **Step 3: Écrire l'implémentation minimale**

Créer `src/lib/auth/post-signup.ts` :

```ts
// src/lib/auth/post-signup.ts — décision post-inscription (pure, testable).
// Le cas « vérification désactivée » manquait : le formulaire ne faisait rien.

export type PostSignupInput = {
  /** `res.data.user.emailVerified` — vrai pour les invités (stampé à la création). */
  emailVerified: boolean;
  /** `env.emailEnabled` côté serveur, passé en prop au formulaire. */
  emailVerificationRequired: boolean;
};

export type PostSignupOutcome =
  | { kind: "redirect" }
  | { kind: "verification-sent" };

export function postSignupOutcome(input: PostSignupInput): PostSignupOutcome {
  if (input.emailVerified) return { kind: "redirect" };
  if (input.emailVerificationRequired) return { kind: "verification-sent" };
  // Vérification désactivée : Better Auth a déjà posé la session.
  return { kind: "redirect" };
}
```

- [ ] **Step 4: Lancer le test pour vérifier qu'il passe**

Run: `bun run test src/lib/auth/post-signup.test.ts`
Expected: PASS — 4 tests.

- [ ] **Step 5: Brancher le module sur le formulaire**

Dans `src/components/signup-form.tsx`, ajouter l'import :

```ts
import { postSignupOutcome } from "@/lib/auth/post-signup";
```

Puis remplacer la fin de `submit()` — le bloc qui teste `res.data?.user?.emailVerified` puis `if (emailVerificationRequired)` — par :

```ts
    const verificationRequired = Boolean(emailVerificationRequired);
    const outcome = postSignupOutcome({
      emailVerified: Boolean(res.data?.user?.emailVerified),
      emailVerificationRequired: verificationRequired,
    });

    if (outcome.kind === "redirect") {
      // Better Auth calcule shouldSkipAutoSignIn depuis requireEmailVerification
      // (sign-up.mjs:162) : quand la vérification est active, signUp.email renvoie
      // token: null et ne pose AUCUN cookie de session. Les invités, stampés
      // vérifiés à la création, arrivent ici : il faut les connecter explicitement.
      // Vérification désactivée = la session est déjà posée par l'inscription.
      if (verificationRequired) {
        const signIn = await authClient.signIn.email({ email, password });
        if (signIn.error) {
          setPending(false);
          setError(signIn.error.message ?? t("loginFailed"));
          return;
        }
      }
      // Pas de setPending(false) : on quitte la page, le bouton doit rester occupé.
      window.location.href = redirectTo ?? AFTER_SIGNUP;
      return;
    }

    setPending(false);
    setVerificationSent(true);
```

**Ne pas supprimer l'appel `authClient.signIn.email` du chemin invité.** Le supprimer enverrait
un invité sur `/onboarding` sans session, donc directement renvoyé au login.

Ne pas introduire `useRouter` : le composant navigue déjà par `window.location.href`.

- [ ] **Step 6: Vérifier que rien n'est cassé**

Run: `bun run test src/lib/auth`
Expected: PASS.

Run: `bunx --bun tsc --noEmit`
Expected: aucune erreur.

- [ ] **Step 7: Vérification visuelle**

Avec d3k (`d3k status --json` d'abord, réutiliser le runtime s'il tourne), créer un compte sur `/signup` dans un environnement sans `RESEND_API_KEY`. Attendu : redirection immédiate vers `/onboarding` au lieu d'un écran figé.

- [ ] **Step 8: Commit**

```bash
git add src/lib/auth/post-signup.ts src/lib/auth/post-signup.test.ts src/components/signup-form.tsx
git commit -m "fix(signup): rediriger quand la verification email est desactivee

Sans provider mail, un signup reussi ne produisait aucun feedback ni
navigation : le bloc de fin de submit() n'avait pas de branche else.
La decision passe dans un module pur teste."
```

---

### Task 2 : Wizard — la sortie ne doit pas être le bouton le moins visible

Sur la dernière étape du wizard, le bouton primaire « Connecter » est désactivé tant que le champ token est vide, « Terminer » n'apparaît qu'après une connexion réussie, et la seule issue est un « Passer » en `variant="ghost"`. Un utilisateur sans compte Mangools n'a donc aucune sortie visible.

Ce correctif est purement présentationnel : aucune logique pure à extraire, donc **pas de test unitaire** — la convention du repo est de ne pas tester le rendu React. Vérification visuelle obligatoire à l'étape 4.

**Files:**
- Modify: `src/components/onboarding/steps/tools-step.tsx` (footer, ~ lignes 100-124)
- Modify: `src/app/onboarding/layout.tsx`
- Modify: `messages/en.json`, `messages/fr.json` (namespace `Onboarding`)

**Interfaces:**
- Consumes: rien.
- Produces: rien (aucun export nouveau).

- [ ] **Step 1: Rendre la sortie toujours visible et non ambiguë**

Dans `src/components/onboarding/steps/tools-step.tsx`, remplacer le footer par :

```tsx
      <CardFooter className="justify-between gap-2">
        <Button variant="outline" onClick={onBack} disabled={finishing}>
          {t("back")}
        </Button>
        <div className="flex flex-wrap justify-end gap-2">
          {connected ? null : (
            <Button
              variant="secondary"
              onClick={connect}
              disabled={pending || finishing || token.trim() === ""}
            >
              {pending ? "…" : t("toolsConnect")}
            </Button>
          )}
          <Button onClick={onNext} disabled={finishing}>
            {t("finish")}
          </Button>
        </div>
      </CardFooter>
```

Deux changements de fond : « Terminer » est **toujours** rendu et en variante primaire — c'est
lui la sortie, on peut finir sans connecter d'outil — et « Connecter » recule en `secondary`.

**Conserver le bouton « Connecter ».** Le supprimer laisserait le champ token à l'écran sans
moyen de le soumettre, c'est-à-dire supprimerait la fonction même de l'étape.

**Ne pas ajouter de bouton « Passer »** en plus de « Terminer » : sur la dernière étape, passer
*est* terminer. Deux boutons appelant le même `onNext` avec le même `disabled` sont un défaut
d'ergonomie, pas une sortie plus visible.

- [ ] **Step 2: Vérifier les clés i18n avant d'en ajouter**

`toolsConnect`, `toolsSkip`, `back` et `finish` existent probablement déjà dans le namespace
`Onboarding`. Lister l'existant avant toute écriture :

```bash
bunx --bun jq '.Onboarding | keys' messages/en.json
```

N'ajouter que ce qui manque réellement, et jamais une clé jumelle d'une clé existante — si
`toolsSkip` existe et que sa valeur est trop laconique, reformuler sa valeur plutôt que créer
`skipTools` à côté. La seule clé nouvelle attendue par cette tâche est `signOut`.

- [ ] **Step 3: Donner une issue au layout du wizard**

Remplacer `src/app/onboarding/layout.tsx` par :

```tsx
// src/app/onboarding/layout.tsx — coquille du parcours d'entrée (hors groupe (app)).
import { getTranslations } from "next-intl/server";
import { SignOutButton } from "@/components/auth/sign-out-button";

export default async function OnboardingLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  const t = await getTranslations("Onboarding");
  return (
    <div className="flex min-h-svh flex-col">
      <header className="flex items-center justify-end p-4">
        <SignOutButton variant="ghost" label={t("signOut")} />
      </header>
      <div className="flex flex-1 items-center justify-center p-6 pt-0">
        {children}
      </div>
    </div>
  );
}
```

**Ne pas créer ce composant : il existe déjà** à `src/components/admin/sign-out-button.tsx`, avec
exactement l'API voulue (`label`, `variant` restreint à `"outline" | "ghost"`, état `pending`,
`window.location.href = "/login"` en `finally`). Il rend déjà un bouton `size="sm"` — il n'y a
donc pas de prop `size` à passer.

Le déplacer plutôt que le dupliquer :

```bash
git mv src/components/admin/sign-out-button.tsx src/components/auth/sign-out-button.tsx
bunx --bun rg -n "admin/sign-out-button" src/
```

Corriger l'unique import trouvé, et adapter son JSDoc qui ne parle aujourd'hui que du shell God
Mode alors qu'il sert désormais aussi l'onboarding. Ne changer ni sa signature ni son
comportement.

Ajouter la clé `signOut` (`"Sign out"` / `"Se déconnecter"`) dans les deux catalogues.

- [ ] **Step 4: Vérifier la parité i18n et le typage**

Run: `bun run test src/i18n`
Expected: PASS — la parité en/fr est vérifiée par `catalogs.test.ts`.

Run: `bunx --bun tsc --noEmit`
Expected: aucune erreur.

- [ ] **Step 5: Vérification visuelle**

Avec d3k, aller jusqu'à la dernière étape de `/onboarding` sans saisir de token. Attendu : « Terminer » visible et actif, « Passer cette étape » visible en `outline`, un bouton de déconnexion en haut à droite.

- [ ] **Step 6: Commit**

```bash
git add src/components/onboarding/steps/tools-step.tsx src/app/onboarding/layout.tsx src/components/auth/sign-out-button.tsx messages/en.json messages/fr.json
git commit -m "fix(onboarding): rendre la sortie du wizard visible

Terminer est desormais toujours affiche et Passer repasse en outline :
un utilisateur sans token Mangools n'avait aucune issue visible. Le
layout expose une deconnexion."
```

---

### Task 3 : Résoudre l'organisation de façon fiable dans le wizard

`src/app/onboarding/page.tsx` lit `session.session.activeOrganizationId` **brut**, sans le repli de `resolveDefaultOrgId`. Sur un signup one-shot, le hook `session.create.before` tourne parfois avant que `createOrgForUser` ait commité : `activeOrgId` est alors `null` alors que l'organisation existe.

**Files:**
- Create: `src/lib/org/onboarding-org.ts`
- Create: `src/lib/org/onboarding-org.test.ts`
- Modify: `src/app/onboarding/page.tsx`

**Interfaces:**
- Consumes: `resolveDefaultOrgId(userId: string): Promise<string | undefined>` depuis `src/lib/org/default-org.ts`.
- Produces: `resolveOnboardingOrgId(userId: string, sessionOrgId: string | null | undefined): Promise<string | null>` — consommé par `src/app/onboarding/page.tsx` et par la Task 4.

- [ ] **Step 1: Écrire le test qui échoue**

Créer `src/lib/org/onboarding-org.test.ts` :

```ts
import { beforeEach, describe, expect, it, mock } from "bun:test";

mock.module("server-only", () => ({}));

const RUN_DB = process.env.DB_TESTS === "1";

describe.skipIf(!RUN_DB)("resolveOnboardingOrgId (postgres)", () => {
  const USER = "onb-org-user-1";
  const ORG = "onb-org-org-1";

  // biome-ignore lint/suspicious/noExplicitAny: modules importés dynamiquement
  let db: any;
  // biome-ignore lint/suspicious/noExplicitAny: modules importés dynamiquement
  let authSchema: any;
  let resolveOnboardingOrgId: (
    userId: string,
    sessionOrgId: string | null | undefined,
  ) => Promise<string | null>;

  async function cleanup() {
    const { eq } = await import("drizzle-orm");
    await db.delete(authSchema.organization).where(eq(authSchema.organization.id, ORG));
    await db.delete(authSchema.user).where(eq(authSchema.user.id, USER));
  }

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    authSchema = await import("@/lib/db/auth-schema");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    await assertTestDb();
    ({ resolveOnboardingOrgId } = await import("./onboarding-org"));

    await cleanup();
    await db.insert(authSchema.user).values({
      id: USER,
      name: "Onb Org",
      email: "onb-org@example.test",
      emailVerified: true,
      createdAt: new Date(),
      updatedAt: new Date(),
    });
    await db.insert(authSchema.organization).values({
      id: ORG,
      name: "Onb Org",
      slug: "onb-org-slug",
      createdAt: new Date(),
    });
    await db.insert(authSchema.member).values({
      id: "onb-org-member-1",
      organizationId: ORG,
      userId: USER,
      role: "owner",
      createdAt: new Date(),
    });
  });

  it("retourne l'org de la session quand elle est présente", async () => {
    expect(await resolveOnboardingOrgId(USER, ORG)).toBe(ORG);
  });

  it("retombe sur l'org par défaut quand la session n'en porte pas", async () => {
    expect(await resolveOnboardingOrgId(USER, null)).toBe(ORG);
  });

  it("retourne null quand l'utilisateur n'a aucune organisation", async () => {
    const { eq } = await import("drizzle-orm");
    await db.delete(authSchema.organization).where(eq(authSchema.organization.id, ORG));
    expect(await resolveOnboardingOrgId(USER, null)).toBeNull();
  });
});
```

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run db:migrate:test && bun run test src/lib/org/onboarding-org.test.ts`
Expected: FAIL — `Cannot find module './onboarding-org'`.

- [ ] **Step 3: Écrire l'implémentation minimale**

Créer `src/lib/org/onboarding-org.ts` :

```ts
// src/lib/org/onboarding-org.ts — résolution d'org tolérante pour /onboarding.
// getActiveOrgId() throw et son throw traverse React cache() : inutilisable ici,
// où l'absence d'org est un état légitime.
import "server-only";
import { resolveDefaultOrgId } from "@/lib/org/default-org";

export async function resolveOnboardingOrgId(
  userId: string,
  sessionOrgId: string | null | undefined,
): Promise<string | null> {
  if (sessionOrgId) return sessionOrgId;
  return (await resolveDefaultOrgId(userId)) ?? null;
}
```

- [ ] **Step 4: Lancer le test pour vérifier qu'il passe**

Run: `bun run test src/lib/org/onboarding-org.test.ts`
Expected: PASS — 3 tests.

- [ ] **Step 5: Brancher sur la page**

Dans `src/app/onboarding/page.tsx`, ajouter l'import :

```ts
import { resolveOnboardingOrgId } from "@/lib/org/onboarding-org";
```

et remplacer le calcul de la prop par :

```tsx
  const activeOrgId = await resolveOnboardingOrgId(
    session.user.id,
    session.session.activeOrganizationId,
  );

  return <OnboardingWizard userName={session.user.name} activeOrgId={activeOrgId} />;
```

- [ ] **Step 6: Vérifier**

Run: `bun run test src/lib/org`
Expected: PASS.

Run: `bunx --bun tsc --noEmit`
Expected: aucune erreur.

- [ ] **Step 7: Commit**

```bash
git add src/lib/org/onboarding-org.ts src/lib/org/onboarding-org.test.ts src/app/onboarding/page.tsx
git commit -m "fix(onboarding): resoudre l'org avec repli au lieu de lire la session brute

Sur un signup one-shot, session.activeOrganizationId peut etre nul alors
que l'org existe : le wizard sautait alors le checkout en silence."
```

---

### Task 4 : Ne plus valider l'onboarding avant l'étape faillible

Deux défauts dans `finish()` : `completeOnboarding()` stampe `onboarding_completed_at` **avant** le checkout, si bien qu'un checkout échoué laisse l'onboarding définitivement terminé sans abonnement ; et quand `activeOrgId` est nul, le checkout est sauté sans le moindre message.

**Files:**
- Create: `src/lib/onboarding/finish-plan.ts`
- Create: `src/lib/onboarding/finish-plan.test.ts`
- Modify: `src/components/onboarding/onboarding-wizard.tsx` (`finish()`, ~ lignes 41-67)
- Modify: `messages/en.json`, `messages/fr.json` (namespace `Billing`)

**Interfaces:**
- Consumes: `resolveOnboardingOrgId` (Task 3) via la prop `activeOrgId`.
- Produces: `planFinishAction(planSelected: boolean, orgId: string | null): FinishAction`.

- [ ] **Step 1: Écrire le test qui échoue**

Créer `src/lib/onboarding/finish-plan.test.ts` :

```ts
import { describe, expect, it } from "bun:test";
import { planFinishAction } from "./finish-plan";

describe("planFinishAction", () => {
  it("termine directement quand aucun forfait payant n'est choisi", () => {
    expect(planFinishAction(false, "org_1")).toEqual({ kind: "complete-and-go" });
  });

  it("termine directement même sans org quand aucun forfait n'est choisi", () => {
    expect(planFinishAction(false, null)).toEqual({ kind: "complete-and-go" });
  });

  it("lance le checkout quand un forfait est choisi et l'org connue", () => {
    expect(planFinishAction(true, "org_1")).toEqual({ kind: "checkout", orgId: "org_1" });
  });

  it("signale une erreur au lieu de sauter le checkout quand l'org est inconnue", () => {
    expect(planFinishAction(true, null)).toEqual({ kind: "error", reason: "no-org" });
  });
});
```

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/onboarding/finish-plan.test.ts`
Expected: FAIL — `Cannot find module './finish-plan'`.

- [ ] **Step 3: Écrire l'implémentation minimale**

Créer `src/lib/onboarding/finish-plan.ts` :

```ts
// src/lib/onboarding/finish-plan.ts — décision de fin de wizard (pure, testable).
// Un forfait choisi sans org connue doit échouer bruyamment : le silence
// faisait croire à l'utilisateur qu'il avait souscrit.

export type FinishAction =
  | { kind: "complete-and-go" }
  | { kind: "checkout"; orgId: string }
  | { kind: "error"; reason: "no-org" };

export function planFinishAction(
  planSelected: boolean,
  orgId: string | null,
): FinishAction {
  if (!planSelected) return { kind: "complete-and-go" };
  if (!orgId) return { kind: "error", reason: "no-org" };
  return { kind: "checkout", orgId };
}
```

- [ ] **Step 4: Lancer le test pour vérifier qu'il passe**

Run: `bun run test src/lib/onboarding/finish-plan.test.ts`
Expected: PASS — 4 tests.

- [ ] **Step 5: Réécrire `finish()`**

Dans `src/components/onboarding/onboarding-wizard.tsx`, ajouter l'import :

```ts
import { planFinishAction } from "@/lib/onboarding/finish-plan";
```

et remplacer intégralement `finish()` par :

```ts
  function finish() {
    startTransition(async () => {
      const action = planFinishAction(Boolean(planSelection), activeOrgId ?? null);

      if (action.kind === "error") {
        toast.error(tBilling("noOrgForCheckout"));
        return; // on ne stampe pas : l'utilisateur peut réessayer
      }

      if (action.kind === "checkout" && planSelection) {
        try {
          const { data, error } = await authClient.subscription.upgrade({
            plan: planSelection.plan,
            annual: planSelection.annual,
            referenceId: action.orgId,
            customerType: "organization",
            successUrl: "/keywords/reservoir",
            cancelUrl: "/keywords/reservoir",
            // On maîtrise la navigation : le plugin redirige tout seul sinon,
            // ce qui entre en course avec le window.location.href final.
            disableRedirect: true,
          });
          if (error) throw error;
          const url = data && "url" in data ? data.url : null;
          if (!url) throw new Error("checkout: no url");
          // Stamper seulement une fois le checkout obtenu.
          await completeOnboarding();
          window.location.href = url;
          return;
        } catch {
          toast.error(tBilling("checkoutError"));
          return; // on ne stampe pas : l'onboarding reste reprenable
        }
      }

      await completeOnboarding();
      window.location.href = "/keywords/reservoir";
    });
  }
```

- [ ] **Step 6: Ajouter la clé i18n**

Dans `messages/en.json`, namespace `Billing` :

```json
    "noOrgForCheckout": "Your workspace is still being created. Wait a moment, then try again."
```

Dans `messages/fr.json`, namespace `Billing` :

```json
    "noOrgForCheckout": "Ton espace de travail est encore en cours de création. Patiente un instant, puis réessaie."
```

- [ ] **Step 7: Vérifier**

Run: `bun run test src/lib/onboarding && bun run test src/i18n`
Expected: PASS.

Run: `bunx --bun tsc --noEmit`
Expected: aucune erreur.

- [ ] **Step 8: Commit**

```bash
git add src/lib/onboarding/finish-plan.ts src/lib/onboarding/finish-plan.test.ts src/components/onboarding/onboarding-wizard.tsx messages/en.json messages/fr.json
git commit -m "fix(onboarding): ne plus valider l'onboarding avant le checkout

completeOnboarding() stampait avant l'etape faillible : un checkout
echoue laissait l'onboarding termine sans abonnement, sans reprise
possible. Un forfait choisi sans org resolue echoue desormais avec un
message au lieu d'etre saute en silence."
```

---

### Task 5 : Membre sans site attribué — écran explicite au lieu d'une app vide

Un membre dont aucun site n'a été partagé voit toutes les pages avec `siteId` vide : tableaux vides, aucun message, aucune action possible. Rien ne lui dit que la correction se fait dans `/settings/team` par un administrateur.

**Files:**
- Create: `src/lib/sites/no-site-access.ts`
- Create: `src/lib/sites/no-site-access.test.ts`
- Create: `src/components/site/no-site-access-notice.tsx`
- Modify: `src/app/(app)/layout.tsx` (après la résolution org/sites)
- Modify: `messages/en.json`, `messages/fr.json` (nouveau namespace `NoSiteAccess`)

**Interfaces:**
- Consumes: `getSitesForActiveOrg()`, `hasFullSiteAccess()` depuis `src/lib/sites/access.ts`.
- Produces: `shouldShowNoSiteAccess(siteCount: number, fullAccess: boolean, isImpersonating: boolean): boolean`.

- [ ] **Step 1: Écrire le test qui échoue**

Créer `src/lib/sites/no-site-access.test.ts` :

```ts
import { describe, expect, it } from "bun:test";
import { shouldShowNoSiteAccess } from "./no-site-access";

describe("shouldShowNoSiteAccess", () => {
  it("s'affiche pour un membre sans aucun site accessible", () => {
    expect(shouldShowNoSiteAccess(0, false, false)).toBe(true);
  });

  it("ne s'affiche pas pour un owner sans site : il doit voir la modale de création", () => {
    expect(shouldShowNoSiteAccess(0, true, false)).toBe(false);
  });

  it("ne s'affiche pas dès qu'au moins un site est accessible", () => {
    expect(shouldShowNoSiteAccess(1, false, false)).toBe(false);
  });

  it("ne s'affiche jamais pendant une impersonation", () => {
    expect(shouldShowNoSiteAccess(0, false, true)).toBe(false);
  });
});
```

- [ ] **Step 2: Lancer le test pour vérifier qu'il échoue**

Run: `bun run test src/lib/sites/no-site-access.test.ts`
Expected: FAIL — `Cannot find module './no-site-access'`.

- [ ] **Step 3: Écrire l'implémentation minimale**

Créer `src/lib/sites/no-site-access.ts` :

```ts
// src/lib/sites/no-site-access.ts — un membre sans site accessible doit voir
// une explication, pas une application vide.

export function shouldShowNoSiteAccess(
  siteCount: number,
  fullAccess: boolean,
  isImpersonating: boolean,
): boolean {
  if (isImpersonating) return false;
  if (fullAccess) return false; // owner/admin : c'est la modale de création qui s'applique
  return siteCount === 0;
}
```

- [ ] **Step 4: Lancer le test pour vérifier qu'il passe**

Run: `bun run test src/lib/sites/no-site-access.test.ts`
Expected: PASS — 4 tests.

- [ ] **Step 5: Créer l'écran**

Créer `src/components/site/no-site-access-notice.tsx` :

```tsx
// src/components/site/no-site-access-notice.tsx
import { getTranslations } from "next-intl/server";
import { Card, CardContent, CardDescription, CardTitle } from "@/components/ui/card";

export async function NoSiteAccessNotice() {
  const t = await getTranslations("NoSiteAccess");
  return (
    <div className="flex min-h-svh items-center justify-center p-6">
      <Card className="max-w-md">
        <CardContent className="flex flex-col gap-3 p-6 text-center md:p-8">
          <CardTitle>{t("title")}</CardTitle>
          <CardDescription>{t("body")}</CardDescription>
        </CardContent>
      </Card>
    </div>
  );
}
```

- [ ] **Step 6: Ajouter les clés i18n**

Dans `messages/en.json`, nouveau namespace de premier niveau :

```json
  "NoSiteAccess": {
    "title": "No site shared with you yet",
    "body": "An owner or admin of your organization needs to give you access to a site from Settings → Team. Once that's done, reload this page."
  }
```

Dans `messages/fr.json` :

```json
  "NoSiteAccess": {
    "title": "Aucun site ne t'a encore été partagé",
    "body": "Un propriétaire ou un administrateur de ton organisation doit te donner accès à un site depuis Réglages → Équipe. Une fois que c'est fait, recharge cette page."
  }
```

- [ ] **Step 7: Brancher sur le layout**

Dans `src/app/(app)/layout.tsx`, ajouter les imports :

```ts
import { NoSiteAccessNotice } from "@/components/site/no-site-access-notice";
import { shouldShowNoSiteAccess } from "@/lib/sites/no-site-access";
```

Puis, **après** le calcul de `showFirstSiteDialog` (qui appelle déjà `hasFullSiteAccess()`) et **avant** le `return` du shell, insérer :

```tsx
  const fullAccess = await hasFullSiteAccess();
  if (shouldShowNoSiteAccess(sites.length, fullAccess, isImpersonating)) {
    return <NoSiteAccessNotice />;
  }
```

Réutiliser la valeur `hasFullSiteAccess()` déjà calculée pour `showFirstSiteDialog` plutôt que d'appeler la fonction deux fois : extraire l'appel dans une constante unique en amont des deux usages.

Ne pas placer ce bloc avant le gate de facturation : un membre d'une organisation expirée doit continuer d'être renvoyé vers `/billing-locked`.

- [ ] **Step 8: Vérifier**

Run: `bun run test src/lib/sites && bun run test src/i18n`
Expected: PASS.

Run: `bunx --bun tsc --noEmit`
Expected: aucune erreur.

- [ ] **Step 9: Vérification visuelle**

Avec d3k : inviter un membre dans une organisation qui possède au moins un site, accepter l'invitation, ne lui attribuer aucun site, puis se connecter avec ce compte. Attendu : l'écran d'explication au lieu d'un réservoir vide.

- [ ] **Step 10: Commit**

```bash
git add src/lib/sites/no-site-access.ts src/lib/sites/no-site-access.test.ts src/components/site/no-site-access-notice.tsx "src/app/(app)/layout.tsx" messages/en.json messages/fr.json
git commit -m "fix(sites): expliquer l'absence d'acces au lieu d'afficher une app vide

Un membre sans site attribue traversait toute l'application avec des
tableaux vides, sans qu'aucun ecran n'indique qu'un administrateur doit
lui donner acces depuis Reglages > Equipe."
```

---

## Fin de branche

- [ ] **Suite complète verte**

Run: `bun run test:all`
Expected: PASS sur l'intégralité de la suite. Ce script lance un process bun par répertoire — c'est la seule mesure fiable, `bun test` global produit des échecs de pollution croisée par fuite de `mock.module`.

- [ ] **Lint et build**

```bash
bun run lint
bunx --bun tsc --noEmit
bun run build
```

- [ ] **Revue du diff complet**

```bash
git diff dev..HEAD
```

Vérifier en particulier qu'aucun `radix-ui` n'a été importé directement et qu'aucune clé i18n n'existe d'un seul côté.

- [ ] **Intégration dans `dev`**

```bash
git checkout dev
git merge fix/onboarding-dead-ends --ff-only
git push
git branch -d fix/onboarding-dead-ends
```

Ne PAS toucher à `main` : la mise en production demande une validation explicite de l'utilisateur.
