# Phase 3 — Org Switcher UI + Onboarding + Accept-Invitation 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:** Donner aux users le contrôle de leur org active via une UI dédiée — créer une nouvelle org (`/onboarding`), basculer entre orgs (sidebar `<OrgSwitcher>`), accepter/rejeter une invitation (`/accept-invitation/[id]`).

**Architecture:** Trois pages publiques nouvelles + un composant client réutilisable + des Server Actions qui wrappent les méthodes Better Auth `organization` plugin (`createOrganization`, `setActiveOrganization`, `acceptInvitation`, `rejectInvitation`). Le switcher est branché dans `app-sidebar.tsx`. Pas de flow d'envoi d'invitation côté Phase 3 — ça arrive en Phase 4 ; on construit seulement la page d'acceptation pour qu'elle soit prête.

**Tech Stack:** Next.js 16 App Router, Better Auth 1.6 (organization plugin), next-intl, shadcn/ui (`Command`, `Popover`, `Button`, `Input`), react-hook-form (déjà installé), nanoid.

**Spec source:** `docs/superpowers/specs/2026-05-25-org-roles-stripe-billing-design.md` §7.1 (arbo), §7.3 (Server Actions `switchActiveOrganization`, `createOrganization`), §7.5 (`<OrgSwitcher>`).

**Hors-scope :**

- Génération d'invitations (Phase 4 — POST email d'invitation, UI invite-form dans `/settings/team`).
- Transfer ownership / delete org (Phase 4).
- Page `/settings/organization` (Phase 4).
- Quota check sur création d'org (Phase 5).

**Dépendances déjà en place :**

- `requireOrgSession()` redirige sur `/onboarding` si pas d'org active. **Cette page existait pas → la création en Phase 3 ferme la boucle.**
- `resolveActiveOrgId()` fallback déjà branché (commit `3736a9ed1`), donc en pratique users existants n'arrivent jamais sur `/onboarding`. Le page sert surtout au case "Create additional org" depuis le switcher.

---

## File Structure

| Path                                                | Status | Responsibility                                                                                                                                                                                                          |
| --------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `lib/actions/organization.ts`                       | create | Server Actions : `switchActiveOrganization(orgId)`, `createOrganization(name, slug?)`, `acceptInvitation(invitationId)`, `rejectInvitation(invitationId)`. Chacune wrappe Better Auth + revalidate les paths concernés. |
| `app/[locale]/onboarding/page.tsx`                  | create | Server Component : form "Create a new organization" (champ `name`, slug auto-généré). Affiche aussi la liste des memberships existantes du user avec un bouton "Switch to this org".                                    |
| `app/[locale]/onboarding/loading.tsx`               | create | Skeleton                                                                                                                                                                                                                |
| `app/[locale]/onboarding/error.tsx`                 | create | Error UI avec retry                                                                                                                                                                                                     |
| `app/[locale]/accept-invitation/[id]/page.tsx`      | create | Server Component : lit invitation par id (Better Auth `getInvitation`), affiche org name + inviter email + buttons Accept/Reject.                                                                                       |
| `app/[locale]/accept-invitation/[id]/loading.tsx`   | create | Skeleton                                                                                                                                                                                                                |
| `app/[locale]/accept-invitation/[id]/error.tsx`     | create | Error UI                                                                                                                                                                                                                |
| `app/[locale]/accept-invitation/[id]/not-found.tsx` | create | Pour invitation invalide / expirée                                                                                                                                                                                      |
| `components/org/org-switcher.tsx`                   | create | `'use client'` Combobox shadcn — liste user's memberships, switch via Server Action, lien "+ Create new organization" → `/onboarding`.                                                                                  |
| `components/app-sidebar.tsx`                        | modify | Insère `<OrgSwitcher>` en haut, juste sous le titre "Eleva Speed".                                                                                                                                                      |
| `messages/{en,fr,es}.json`                          | modify | Ajoute namespaces `Org.switcher`, `Onboarding`, `Invitation`.                                                                                                                                                           |
| `scripts/verify-org-switch.ts`                      | create | Smoke test : signup user, créé une 2e org manuellement, switch active org, assert.                                                                                                                                      |
| `package.json`                                      | modify | Script `verify:org-switch`.                                                                                                                                                                                             |

---

## Task 1: Server Actions in `lib/actions/organization.ts`

**Files:**

- Create: `lib/actions/organization.ts`

- [ ] **Step 1.1: Create the file with 4 actions**

```ts
"use server";

import { revalidatePath } from "next/cache";
import { headers } from "next/headers";

import { auth } from "@/lib/auth";

/* Switch the active org for the current session. Uses Better Auth's
   setActiveOrganization endpoint, which updates session.activeOrganizationId
   in DB. Revalidates the dashboard layout so all server queries refetch
   under the new org. */
export async function switchActiveOrganization(organizationId: string) {
  await auth.api.setActiveOrganization({
    headers: await headers(),
    body: { organizationId },
  });
  revalidatePath("/", "layout");
}

/* Create a new org for the current user. The user becomes its `owner`
   automatically (Better Auth org plugin behavior). The new org also
   becomes the session's active org. */
export async function createOrganization(input: { name: string; slug?: string }) {
  const result = await auth.api.createOrganization({
    headers: await headers(),
    body: {
      name: input.name,
      slug:
        (input.slug ??
          input.name
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, "-")
            .replace(/(^-|-$)/g, "")
            .slice(0, 50)) ||
        `org-${Date.now()}`,
    },
  });
  if (!result) throw new Error("Failed to create organization");
  // setActiveOrganization on the just-created org.
  await auth.api.setActiveOrganization({
    headers: await headers(),
    body: { organizationId: result.id },
  });
  revalidatePath("/", "layout");
  return result;
}

/* Accept an invitation by id. Better Auth's acceptInvitation inserts a
   member row and marks the invitation as accepted. We then make that org
   the active one (UX: just-accepted invite → land in that workspace). */
export async function acceptInvitation(invitationId: string) {
  const result = await auth.api.acceptInvitation({
    headers: await headers(),
    body: { invitationId },
  });
  if (result?.invitation?.organizationId) {
    await auth.api.setActiveOrganization({
      headers: await headers(),
      body: { organizationId: result.invitation.organizationId },
    });
  }
  revalidatePath("/", "layout");
  return result;
}

/* Reject an invitation. Better Auth marks it as rejected (no member row
   inserted). */
export async function rejectInvitation(invitationId: string) {
  await auth.api.rejectInvitation({
    headers: await headers(),
    body: { invitationId },
  });
}
```

- [ ] **Step 1.2: Typecheck**

```bash
cd /Users/humperdoo/Github/eleva-speed && pnpm typecheck 2>&1 | tail -5
```

Expected: 0 errors. If Better Auth's endpoint shape differs (e.g., `result.organizationId` instead of `result.invitation.organizationId`), adjust accordingly — read `node_modules/better-auth/dist/plugins/organization/organization.d.mts` to confirm the return type of `acceptInvitation`.

- [ ] **Step 1.3: Commit**

```bash
git add lib/actions/organization.ts
git commit -m "feat(actions): add organization Server Actions (switch/create/accept/reject)

Thin wrappers around Better Auth's organization plugin endpoints. Each
action revalidates the root layout so all org-scoped queries refetch.
createOrganization also auto-generates a slug from the name + sets the
new org as active. acceptInvitation lands the user in the just-joined
org. To be consumed by /onboarding, <OrgSwitcher>, and /accept-invitation
in the following commits."
```

---

## Task 2: i18n keys for Phase 3

**Files:**

- Modify: `messages/en.json`
- Modify: `messages/fr.json`
- Modify: `messages/es.json`

- [ ] **Step 2.1: Plan the new namespaces**

We need 3 namespaces:

- `Org.switcher` — strings for the sidebar switcher (current org label, search placeholder, "Create new organization" item)
- `Onboarding` — title, description, form field labels, submit button, existing-memberships section
- `Invitation` — page title, "You've been invited to {org}", accept/reject buttons, expired/notFound states

- [ ] **Step 2.2: Read `messages/en.json` to find the existing structure**

Note where existing namespaces (`Sidebar`, `Dashboard`, `Admin`, `TestReport`, etc.) live and the casing convention used.

- [ ] **Step 2.3: Add the 3 new namespaces to all 3 message files**

For `messages/en.json` (insert at a sensible location, e.g., right before or after `Sidebar`):

```json
{
  "Org": {
    "switcher": {
      "currentOrg": "Current organization",
      "searchPlaceholder": "Search organizations…",
      "createNew": "+ Create new organization",
      "noResults": "No organization found.",
      "switching": "Switching…",
      "switchFailed": "Failed to switch organization."
    }
  },
  "Onboarding": {
    "title": "Create a new organization",
    "description": "Organizations let you separate workspaces — for example a team-shared workspace alongside your personal one.",
    "nameLabel": "Organization name",
    "namePlaceholder": "Acme Corp",
    "submit": "Create organization",
    "submitting": "Creating…",
    "createFailed": "Failed to create organization.",
    "existingTitle": "Or switch to one of your existing organizations",
    "existingSwitchLabel": "Switch to this organization"
  },
  "Invitation": {
    "title": "Join {orgName}",
    "youWereInvited": "{inviterEmail} invited you to join {orgName} as {role}.",
    "accept": "Accept invitation",
    "reject": "Decline",
    "accepting": "Joining…",
    "rejecting": "Declining…",
    "acceptFailed": "Failed to accept invitation.",
    "rejectFailed": "Failed to decline invitation.",
    "notFoundTitle": "Invitation not found",
    "notFoundDescription": "This invitation may have expired or been revoked. Ask the person who invited you to send a new one.",
    "expiredTitle": "Invitation expired",
    "expiredDescription": "This invitation is no longer valid. Ask the organization owner to send a new one.",
    "alreadyMember": "You're already a member of this organization."
  }
}
```

For `messages/fr.json`:

```json
{
  "Org": {
    "switcher": {
      "currentOrg": "Organisation active",
      "searchPlaceholder": "Rechercher une organisation…",
      "createNew": "+ Créer une nouvelle organisation",
      "noResults": "Aucune organisation trouvée.",
      "switching": "Basculement…",
      "switchFailed": "Impossible de basculer d'organisation."
    }
  },
  "Onboarding": {
    "title": "Créer une nouvelle organisation",
    "description": "Les organisations vous permettent de séparer vos espaces de travail — par exemple un espace partagé en équipe à côté de votre espace personnel.",
    "nameLabel": "Nom de l'organisation",
    "namePlaceholder": "Acme Corp",
    "submit": "Créer l'organisation",
    "submitting": "Création…",
    "createFailed": "Impossible de créer l'organisation.",
    "existingTitle": "Ou basculer vers l'une de vos organisations existantes",
    "existingSwitchLabel": "Basculer vers cette organisation"
  },
  "Invitation": {
    "title": "Rejoindre {orgName}",
    "youWereInvited": "{inviterEmail} vous a invité à rejoindre {orgName} en tant que {role}.",
    "accept": "Accepter l'invitation",
    "reject": "Refuser",
    "accepting": "Adhésion…",
    "rejecting": "Refus…",
    "acceptFailed": "Impossible d'accepter l'invitation.",
    "rejectFailed": "Impossible de refuser l'invitation.",
    "notFoundTitle": "Invitation introuvable",
    "notFoundDescription": "Cette invitation a peut-être expiré ou été révoquée. Demandez à la personne qui vous a invité d'en envoyer une nouvelle.",
    "expiredTitle": "Invitation expirée",
    "expiredDescription": "Cette invitation n'est plus valide. Demandez au propriétaire de l'organisation d'en envoyer une nouvelle.",
    "alreadyMember": "Vous êtes déjà membre de cette organisation."
  }
}
```

For `messages/es.json`:

```json
{
  "Org": {
    "switcher": {
      "currentOrg": "Organización activa",
      "searchPlaceholder": "Buscar una organización…",
      "createNew": "+ Crear una nueva organización",
      "noResults": "Ninguna organización encontrada.",
      "switching": "Cambiando…",
      "switchFailed": "No se pudo cambiar de organización."
    }
  },
  "Onboarding": {
    "title": "Crear una nueva organización",
    "description": "Las organizaciones le permiten separar sus espacios de trabajo — por ejemplo un espacio compartido en equipo junto a su espacio personal.",
    "nameLabel": "Nombre de la organización",
    "namePlaceholder": "Acme Corp",
    "submit": "Crear organización",
    "submitting": "Creando…",
    "createFailed": "No se pudo crear la organización.",
    "existingTitle": "O cambiar a una de sus organizaciones existentes",
    "existingSwitchLabel": "Cambiar a esta organización"
  },
  "Invitation": {
    "title": "Unirse a {orgName}",
    "youWereInvited": "{inviterEmail} le invitó a unirse a {orgName} como {role}.",
    "accept": "Aceptar invitación",
    "reject": "Rechazar",
    "accepting": "Uniéndose…",
    "rejecting": "Rechazando…",
    "acceptFailed": "No se pudo aceptar la invitación.",
    "rejectFailed": "No se pudo rechazar la invitación.",
    "notFoundTitle": "Invitación no encontrada",
    "notFoundDescription": "Esta invitación puede haber caducado o haber sido revocada. Pida a la persona que le invitó que envíe una nueva.",
    "expiredTitle": "Invitación caducada",
    "expiredDescription": "Esta invitación ya no es válida. Pida al propietario de la organización que envíe una nueva.",
    "alreadyMember": "Ya es miembro de esta organización."
  }
}
```

- [ ] **Step 2.4: Verify JSON parses + key parity**

```bash
cd /Users/humperdoo/Github/eleva-speed
jq . messages/en.json > /dev/null && jq . messages/fr.json > /dev/null && jq . messages/es.json > /dev/null
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/fr.json | sort)
diff <(jq -r 'paths(scalars) | join(".")' messages/en.json | sort) <(jq -r 'paths(scalars) | join(".")' messages/es.json | sort)
```

Both diffs must be empty.

- [ ] **Step 2.5: Commit**

```bash
git add messages/
git commit -m "i18n: add Org.switcher / Onboarding / Invitation namespaces (EN/FR/ES)

Translation keys for the Phase 3 UI (switcher, onboarding page, accept-
invitation page). All three locales kept in key-parity."
```

---

## Task 3: `<OrgSwitcher>` component

**Files:**

- Create: `components/org/org-switcher.tsx`

- [ ] **Step 3.1: Create the file**

```tsx
"use client";

import { useState, useTransition } from "react";

import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

import { switchActiveOrganization } from "@/lib/actions/organization";
import { Button } from "@/components/ui/button";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { IconCheck, IconChevronsUpDown, IconPlus } from "@tabler/icons-react";
import { cn } from "@/lib/utils";

export type OrgSwitcherItem = {
  id: string;
  name: string;
  role: "owner" | "admin" | "member";
};

export function OrgSwitcher({
  organizations,
  activeOrganizationId,
}: {
  organizations: OrgSwitcherItem[];
  activeOrganizationId: string | null;
}) {
  const t = useTranslations("Org.switcher");
  const router = useRouter();
  const [open, setOpen] = useState(false);
  const [isPending, startTransition] = useTransition();

  const active = organizations.find((o) => o.id === activeOrganizationId);

  function handleSwitch(orgId: string) {
    if (orgId === activeOrganizationId) {
      setOpen(false);
      return;
    }
    setOpen(false);
    startTransition(async () => {
      try {
        await switchActiveOrganization(orgId);
        // revalidatePath in the action triggers Next.js to re-render the
        // RSC tree; no explicit router.refresh needed but doesn't hurt.
        router.refresh();
      } catch {
        toast.error(t("switchFailed"));
      }
    });
  }

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={open}
          aria-label={t("currentOrg")}
          className="w-full justify-between"
          disabled={isPending}
        >
          <span className="truncate text-left">
            {isPending ? t("switching") : (active?.name ?? "—")}
          </span>
          <IconChevronsUpDown className="size-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-[260px] p-0" align="start">
        <Command>
          <CommandInput placeholder={t("searchPlaceholder")} />
          <CommandList>
            <CommandEmpty>{t("noResults")}</CommandEmpty>
            <CommandGroup>
              {organizations.map((org) => (
                <CommandItem key={org.id} value={org.name} onSelect={() => handleSwitch(org.id)}>
                  <IconCheck
                    className={cn(
                      "mr-2 size-4",
                      org.id === activeOrganizationId ? "opacity-100" : "opacity-0",
                    )}
                  />
                  <span className="truncate">{org.name}</span>
                  <span className="ml-auto text-xs uppercase tracking-wider opacity-60">
                    {org.role}
                  </span>
                </CommandItem>
              ))}
            </CommandGroup>
            <CommandSeparator />
            <CommandGroup>
              <CommandItem
                onSelect={() => {
                  setOpen(false);
                  router.push("/onboarding");
                }}
              >
                <IconPlus className="mr-2 size-4" />
                {t("createNew")}
              </CommandItem>
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  );
}
```

- [ ] **Step 3.2: Verify shadcn imports exist**

Check that `@/components/ui/command` and `@/components/ui/popover` exist:

```bash
ls components/ui/command.tsx components/ui/popover.tsx
```

If either is missing, add it via shadcn CLI (NOT in this step — report BLOCKED and add the install command). Both should already be present from earlier work.

- [ ] **Step 3.3: Typecheck + lint**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm typecheck 2>&1 | grep "org-switcher" | head -5
pnpm lint components/org/org-switcher.tsx 2>&1 | tail -3
```

Expected: 0 errors.

- [ ] **Step 3.4: Commit**

```bash
git add components/org/
git commit -m "feat(org): add <OrgSwitcher> Combobox component

Client-side Combobox listing user's memberships with the active one
checkmarked. Switching calls switchActiveOrganization Server Action
+ router.refresh. Bottom item links to /onboarding for creating a new
org. Uses shadcn Command + Popover primitives + role badge."
```

---

## Task 4: Wire `<OrgSwitcher>` into `app-sidebar.tsx`

**Files:**

- Modify: `components/app-sidebar.tsx`

- [ ] **Step 4.1: Read the sidebar header section**

The sidebar has a `<SidebarHeader>` containing the "Eleva Speed" title (around line 100-109). The `<OrgSwitcher>` should go right under it. The switcher needs `organizations` + `activeOrganizationId` props — fetched server-side via a new hook or via props passed down from a wrapping Server Component.

The current `app-sidebar.tsx` is a `'use client'` component. Two options:

- **Option A**: Pass `organizations` + `activeOrganizationId` as props from a Server Component wrapper (cleanest).
- **Option B**: Add a `'use client'` data-fetching hook that calls a new `/api/organizations` endpoint (more work, less clean).

Use **Option A**: the existing layout (`app/[locale]/dashboard/layout.tsx`) already resolves session server-side; pass the orgs list down.

- [ ] **Step 4.2: Add a Server Action / fetcher for the org list**

In `lib/actions/organization.ts` (which we created in Task 1), add at the bottom:

```ts
import { eq } from "drizzle-orm";

import { db } from "@/lib/db";
import { member, organization } from "@/lib/db/schema";

/* Returns the user's memberships joined with org names — used to
   populate the <OrgSwitcher> dropdown. */
export async function listUserOrganizations(userId: string) {
  const rows = await db
    .select({
      id: organization.id,
      name: organization.name,
      role: member.role,
    })
    .from(member)
    .innerJoin(organization, eq(member.organizationId, organization.id))
    .where(eq(member.userId, userId))
    .orderBy(organization.name);
  return rows.map((r) => ({
    id: r.id,
    name: r.name,
    role: r.role as "owner" | "admin" | "member",
  }));
}
```

Note: this is **not** a `"use server"` mutation — it's a plain async function exported from a file that has `"use server"` at the top. Move it to a separate `lib/data/organization.ts` file to avoid the Server Action constraint (every export in a `"use server"` file must be a Server Action that takes only serializable args).

Actually — the cleanest is to put data-fetchers in `lib/data/organization.ts` (no `"use server"` directive) and mutations in `lib/actions/organization.ts` (with `"use server"`).

Create a new file `lib/data/organization.ts`:

```ts
import { eq } from "drizzle-orm";

import { db } from "@/lib/db";
import { member, organization } from "@/lib/db/schema";

/* Returns the user's memberships joined with org names — used to
   populate the <OrgSwitcher> dropdown and the /onboarding page. */
export async function listUserOrganizations(userId: string) {
  const rows = await db
    .select({
      id: organization.id,
      name: organization.name,
      role: member.role,
    })
    .from(member)
    .innerJoin(organization, eq(member.organizationId, organization.id))
    .where(eq(member.userId, userId))
    .orderBy(organization.name);
  return rows.map((r) => ({
    id: r.id,
    name: r.name,
    role: r.role as "owner" | "admin" | "member",
  }));
}
```

- [ ] **Step 4.3: Find where `app-sidebar.tsx` is rendered**

```bash
grep -rln "AppSidebar\|app-sidebar" app/ components/ 2>/dev/null | head -5
```

The component is consumed by the dashboard layout. Read the dashboard layout file to see how it's wired and where we should pass the org props from.

- [ ] **Step 4.4: Update the dashboard layout to pass org props**

In `app/[locale]/dashboard/layout.tsx` (or wherever `<AppSidebar>` is rendered), call `listUserOrganizations(session.user.id)` server-side and pass the result + the active org id to `<AppSidebar>` as new props. The layout already resolves the session via `requireOrgSession` (or it should — if not, add it). Concretely:

Read `app/[locale]/dashboard/layout.tsx` and locate the `<AppSidebar />` render. Add the props:

```tsx
import { listUserOrganizations } from "@/lib/data/organization";
import { requireOrgSession } from "@/lib/auth-guards";

// In the layout's Server Component body:
const { session, activeOrganizationId } = await requireOrgSession();
const organizations = await listUserOrganizations(session.user.id);

return (
  // ...
  <AppSidebar
    organizations={organizations}
    activeOrganizationId={activeOrganizationId}
    // ... existing props ...
  />
);
```

(The exact shape of the layout depends on what's there — adapt carefully.)

- [ ] **Step 4.5: Update `app-sidebar.tsx` to accept + render the switcher**

Add to the props type:

```tsx
type AppSidebarProps = {
  // ... existing props ...
  organizations: { id: string; name: string; role: "owner" | "admin" | "member" }[];
  activeOrganizationId: string | null;
};
```

In the render, just under the `<SidebarMenu>` containing the "Eleva Speed" title, add:

```tsx
import { OrgSwitcher } from "@/components/org/org-switcher";

// In the JSX, after the "Eleva Speed" SidebarMenu:
<div className="px-2 pt-2">
  <OrgSwitcher organizations={organizations} activeOrganizationId={activeOrganizationId} />
</div>;
```

- [ ] **Step 4.6: Typecheck + lint**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm typecheck 2>&1 | tail -5
pnpm lint components/app-sidebar.tsx 'app/[locale]/dashboard/layout.tsx' 2>&1 | tail -3
```

Expected: 0 errors.

- [ ] **Step 4.7: Commit**

```bash
git add components/app-sidebar.tsx 'app/[locale]/dashboard/layout.tsx' lib/data/organization.ts
git commit -m "feat(sidebar): integrate <OrgSwitcher> into AppSidebar

Adds a new lib/data/organization.ts with listUserOrganizations(userId)
that joins member ↔ organization for the dropdown. The dashboard layout
resolves the org list server-side and passes it down to <AppSidebar>,
which now renders <OrgSwitcher> right below the brand title. Active org
is fetched via requireOrgSession (uses the activeOrganizationId fallback
from auth-guards)."
```

---

## Task 5: `/onboarding` page

**Files:**

- Create: `app/[locale]/onboarding/page.tsx`
- Create: `app/[locale]/onboarding/loading.tsx`
- Create: `app/[locale]/onboarding/error.tsx`

- [ ] **Step 5.1: Create `page.tsx`**

```tsx
import { headers } from "next/headers";
import { getTranslations } from "next-intl/server";

import { auth } from "@/lib/auth";
import { listUserOrganizations } from "@/lib/data/organization";
import { redirect } from "@/i18n/navigation";
import { routing, type Locale } from "@/i18n/routing";
import { OnboardingForm } from "./onboarding-form";
import { ExistingOrgsList } from "./existing-orgs-list";

export default async function OnboardingPage({ params }: { params: Promise<{ locale: Locale }> }) {
  const { locale } = await params;
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect({ href: "/login", locale: locale ?? routing.defaultLocale });

  const t = await getTranslations("Onboarding");
  const orgs = await listUserOrganizations(session!.user.id);

  return (
    <main className="mx-auto flex max-w-md flex-col gap-6 px-4 py-12">
      <header className="flex flex-col gap-2 text-center">
        <h1 className="text-2xl font-semibold tracking-tight">{t("title")}</h1>
        <p className="text-sm text-muted-foreground">{t("description")}</p>
      </header>

      <OnboardingForm />

      {orgs.length > 0 && (
        <section className="flex flex-col gap-3 border-t pt-6">
          <h2 className="text-sm font-medium text-muted-foreground">{t("existingTitle")}</h2>
          <ExistingOrgsList organizations={orgs} />
        </section>
      )}
    </main>
  );
}
```

- [ ] **Step 5.2: Create the client form `onboarding-form.tsx`**

```tsx
"use client";

import { useState, useTransition } from "react";

import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";

import { createOrganization } from "@/lib/actions/organization";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function OnboardingForm() {
  const t = useTranslations("Onboarding");
  const router = useRouter();
  const [name, setName] = useState("");
  const [isPending, startTransition] = useTransition();

  function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const trimmed = name.trim();
    if (!trimmed) return;
    startTransition(async () => {
      try {
        await createOrganization({ name: trimmed });
        router.push("/dashboard");
      } catch {
        toast.error(t("createFailed"));
      }
    });
  }

  return (
    <form onSubmit={onSubmit} className="flex flex-col gap-3">
      <div className="flex flex-col gap-2">
        <Label htmlFor="org-name">{t("nameLabel")}</Label>
        <Input
          id="org-name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder={t("namePlaceholder")}
          required
          autoFocus
        />
      </div>
      <Button type="submit" disabled={isPending || !name.trim()}>
        {isPending ? t("submitting") : t("submit")}
      </Button>
    </form>
  );
}
```

- [ ] **Step 5.3: Create the client list `existing-orgs-list.tsx`**

```tsx
"use client";

import { useTransition } from "react";

import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";

import { switchActiveOrganization } from "@/lib/actions/organization";
import { Button } from "@/components/ui/button";

export function ExistingOrgsList({
  organizations,
}: {
  organizations: { id: string; name: string; role: "owner" | "admin" | "member" }[];
}) {
  const t = useTranslations("Onboarding");
  const tSwitcher = useTranslations("Org.switcher");
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  function handleSwitch(orgId: string) {
    startTransition(async () => {
      try {
        await switchActiveOrganization(orgId);
        router.push("/dashboard");
      } catch {
        toast.error(tSwitcher("switchFailed"));
      }
    });
  }

  return (
    <ul className="flex flex-col gap-2">
      {organizations.map((org) => (
        <li key={org.id} className="flex items-center justify-between rounded-md border px-3 py-2">
          <span className="flex items-center gap-2">
            <span className="font-medium">{org.name}</span>
            <span className="text-xs uppercase tracking-wider text-muted-foreground">
              {org.role}
            </span>
          </span>
          <Button
            type="button"
            variant="ghost"
            size="sm"
            disabled={isPending}
            onClick={() => handleSwitch(org.id)}
          >
            {t("existingSwitchLabel")}
          </Button>
        </li>
      ))}
    </ul>
  );
}
```

These two need to be CO-LOCATED with `page.tsx` (same directory). Create them as files in `app/[locale]/onboarding/`.

- [ ] **Step 5.4: Create `loading.tsx`**

```tsx
import { Skeleton } from "@/components/ui/skeleton";

export default function Loading() {
  return (
    <main className="mx-auto flex max-w-md flex-col gap-6 px-4 py-12">
      <Skeleton className="h-8 w-3/4 self-center" />
      <Skeleton className="h-4 w-full" />
      <div className="flex flex-col gap-3">
        <Skeleton className="h-4 w-1/3" />
        <Skeleton className="h-10 w-full" />
        <Skeleton className="h-10 w-full" />
      </div>
    </main>
  );
}
```

- [ ] **Step 5.5: Create `error.tsx`**

```tsx
"use client";

import { Button } from "@/components/ui/button";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <main className="mx-auto flex max-w-md flex-col gap-4 px-4 py-12 text-center">
      <h1 className="text-xl font-semibold">Something went wrong</h1>
      <p className="text-sm text-muted-foreground">{error.message}</p>
      <Button type="button" onClick={reset}>
        Try again
      </Button>
    </main>
  );
}
```

(Error boundaries can't use server `getTranslations`; the strings here are intentionally minimal and English-only — acceptable for an error fallback. Could be translated later.)

- [ ] **Step 5.6: Typecheck + lint**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm typecheck 2>&1 | tail -5
pnpm lint 'app/[locale]/onboarding/' 2>&1 | tail -3
```

Expected: 0 errors.

- [ ] **Step 5.7: Commit**

```bash
git add 'app/[locale]/onboarding/'
git commit -m "feat(onboarding): /onboarding page to create a new org or switch to an existing one

Server Component lists the user's existing memberships (via lib/data/
organization.listUserOrganizations) and renders a client form to create
a new org via createOrganization Server Action. After creation, redirects
to /dashboard with the new org as active. The existing-orgs list lets
the user jump to a different membership without going through the
sidebar switcher.

Adds loading.tsx (skeleton) + error.tsx (English-only fallback)."
```

---

## Task 6: `/accept-invitation/[id]` page

**Files:**

- Create: `app/[locale]/accept-invitation/[id]/page.tsx`
- Create: `app/[locale]/accept-invitation/[id]/loading.tsx`
- Create: `app/[locale]/accept-invitation/[id]/error.tsx`
- Create: `app/[locale]/accept-invitation/[id]/not-found.tsx`
- Create: `app/[locale]/accept-invitation/[id]/invitation-actions.tsx` (client)

- [ ] **Step 6.1: Create `page.tsx`**

```tsx
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";

import { auth } from "@/lib/auth";
import { redirect } from "@/i18n/navigation";
import { routing, type Locale } from "@/i18n/routing";
import { InvitationActions } from "./invitation-actions";

export default async function AcceptInvitationPage({
  params,
}: {
  params: Promise<{ id: string; locale: Locale }>;
}) {
  const { id: invitationId, locale } = await params;

  // Require login — accepting an invite must be tied to an authenticated user.
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) {
    redirect({
      href: `/login?next=/accept-invitation/${invitationId}`,
      locale: locale ?? routing.defaultLocale,
    });
  }

  const t = await getTranslations("Invitation");

  // Fetch the invitation via Better Auth.
  let invitation: Awaited<ReturnType<typeof auth.api.getInvitation>> | null = null;
  try {
    invitation = await auth.api.getInvitation({
      headers: await headers(),
      query: { id: invitationId },
    });
  } catch {
    notFound();
  }
  if (!invitation) notFound();

  // Expired check (Better Auth may already return null for expired, but be defensive).
  const expiresAt = invitation.expiresAt ? new Date(invitation.expiresAt) : null;
  const isExpired = expiresAt ? expiresAt < new Date() : false;

  if (isExpired) {
    return (
      <main className="mx-auto flex max-w-md flex-col gap-4 px-4 py-12 text-center">
        <h1 className="text-xl font-semibold">{t("expiredTitle")}</h1>
        <p className="text-sm text-muted-foreground">{t("expiredDescription")}</p>
      </main>
    );
  }

  const orgName = (invitation as { organizationName?: string }).organizationName ?? "—";
  const inviterEmail = (invitation as { inviterEmail?: string }).inviterEmail ?? "—";
  const role = invitation.role ?? "member";

  return (
    <main className="mx-auto flex max-w-md flex-col gap-6 px-4 py-12">
      <header className="flex flex-col gap-2 text-center">
        <h1 className="text-2xl font-semibold tracking-tight">{t("title", { orgName })}</h1>
        <p className="text-sm text-muted-foreground">
          {t("youWereInvited", { inviterEmail, orgName, role })}
        </p>
      </header>

      <InvitationActions invitationId={invitationId} />
    </main>
  );
}
```

- [ ] **Step 6.2: Create `invitation-actions.tsx` (client)**

```tsx
"use client";

import { useTransition } from "react";

import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";

import { acceptInvitation, rejectInvitation } from "@/lib/actions/organization";
import { Button } from "@/components/ui/button";

export function InvitationActions({ invitationId }: { invitationId: string }) {
  const t = useTranslations("Invitation");
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  function onAccept() {
    startTransition(async () => {
      try {
        await acceptInvitation(invitationId);
        router.push("/dashboard");
      } catch {
        toast.error(t("acceptFailed"));
      }
    });
  }

  function onReject() {
    startTransition(async () => {
      try {
        await rejectInvitation(invitationId);
        router.push("/");
      } catch {
        toast.error(t("rejectFailed"));
      }
    });
  }

  return (
    <div className="flex flex-col gap-3">
      <Button type="button" onClick={onAccept} disabled={isPending}>
        {isPending ? t("accepting") : t("accept")}
      </Button>
      <Button type="button" variant="ghost" onClick={onReject} disabled={isPending}>
        {isPending ? t("rejecting") : t("reject")}
      </Button>
    </div>
  );
}
```

- [ ] **Step 6.3: Create `loading.tsx`**

```tsx
import { Skeleton } from "@/components/ui/skeleton";

export default function Loading() {
  return (
    <main className="mx-auto flex max-w-md flex-col gap-6 px-4 py-12">
      <Skeleton className="mx-auto h-8 w-3/4" />
      <Skeleton className="mx-auto h-4 w-full" />
      <div className="flex flex-col gap-3">
        <Skeleton className="h-10 w-full" />
        <Skeleton className="h-10 w-full" />
      </div>
    </main>
  );
}
```

- [ ] **Step 6.4: Create `error.tsx`**

```tsx
"use client";

import { Button } from "@/components/ui/button";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <main className="mx-auto flex max-w-md flex-col gap-4 px-4 py-12 text-center">
      <h1 className="text-xl font-semibold">Something went wrong</h1>
      <p className="text-sm text-muted-foreground">{error.message}</p>
      <Button type="button" onClick={reset}>
        Try again
      </Button>
    </main>
  );
}
```

- [ ] **Step 6.5: Create `not-found.tsx`**

```tsx
import { getTranslations } from "next-intl/server";

export default async function NotFound() {
  const t = await getTranslations("Invitation");
  return (
    <main className="mx-auto flex max-w-md flex-col gap-4 px-4 py-12 text-center">
      <h1 className="text-xl font-semibold">{t("notFoundTitle")}</h1>
      <p className="text-sm text-muted-foreground">{t("notFoundDescription")}</p>
    </main>
  );
}
```

- [ ] **Step 6.6: Typecheck + lint**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm typecheck 2>&1 | tail -5
pnpm lint 'app/[locale]/accept-invitation/' 2>&1 | tail -3
```

Expected: 0 errors. If `auth.api.getInvitation`'s return type doesn't match what's used in `page.tsx` (e.g., property names like `organizationName` / `inviterEmail` differ), adjust by reading the Better Auth org plugin types: `node_modules/better-auth/dist/plugins/organization/organization.d.mts`.

- [ ] **Step 6.7: Commit**

```bash
git add 'app/[locale]/accept-invitation/'
git commit -m "feat(invitation): /accept-invitation/[id] landing page with Accept/Decline

Server Component fetches the invitation via Better Auth's getInvitation,
shows org name + inviter email + role, and renders client Accept/Decline
buttons that call acceptInvitation/rejectInvitation Server Actions.
Includes loading.tsx (skeleton), error.tsx (English fallback), and
translated not-found.tsx for invalid/expired invitations. The accept
flow lands the user in the just-joined org via setActiveOrganization."
```

---

## Task 7: Smoke test for org switching

**Files:**

- Create: `scripts/verify-org-switch.ts`
- Modify: `package.json`

- [ ] **Step 7.1: Create the smoke test**

```ts
import { and, eq, like, notExists } from "drizzle-orm";
import { nanoid } from "nanoid";

import { auth } from "../lib/auth";
import { db } from "../lib/db";
import { member, organization, session, user } from "../lib/db/schema";

const TEST_EMAIL = `smoke-switch-${nanoid(8)}@elevaseo.com`;
const TEST_NAME = "Smoke Switch";
const TEST_PASSWORD = "SmokeSwitch1234!";

async function main() {
  console.log(`→ Signing up ${TEST_EMAIL}`);
  const res = await auth.api.signUpEmail({
    body: { name: TEST_NAME, email: TEST_EMAIL, password: TEST_PASSWORD },
  });
  const userId = res.user.id;

  try {
    // 1. Resolve the user's auto-created Personal Org (Phase 1 hook).
    const initialMember = await db.select().from(member).where(eq(member.userId, userId)).limit(1);
    if (initialMember.length === 0) throw new Error("Personal org missing");
    const personalOrgId = initialMember[0]!.organizationId;
    console.log(`✓ Personal org: ${personalOrgId}`);

    // 2. Create a SECOND org for this user, directly via DB (no UI yet).
    const teamOrgId = nanoid();
    await db.transaction(async (tx) => {
      await tx.insert(organization).values({
        id: teamOrgId,
        name: `Team Smoke ${nanoid(4)}`,
        slug: `team-smoke-${nanoid(6)}`,
        logo: null,
        metadata: null,
      });
      await tx.insert(member).values({
        id: nanoid(),
        organizationId: teamOrgId,
        userId,
        role: "admin",
      });
    });
    console.log(`✓ Team org: ${teamOrgId}`);

    // 3. The user now has 2 memberships. Verify.
    const allMembers = await db.select().from(member).where(eq(member.userId, userId));
    if (allMembers.length !== 2) {
      throw new Error(`Expected 2 memberships, got ${allMembers.length}`);
    }

    // 4. Simulate the switch via Better Auth API (the same call the
    //    Server Action makes). We need a session for the call — fetch
    //    the most recent one inserted by signUpEmail.
    const sess = await db
      .select()
      .from(session)
      .where(eq(session.userId, userId))
      .orderBy(session.createdAt)
      .limit(1);
    if (sess.length === 0) throw new Error("No session row");

    // Manually update session.activeOrganizationId to the team org —
    // matches what setActiveOrganization would do under the hood.
    await db
      .update(session)
      .set({ activeOrganizationId: teamOrgId })
      .where(eq(session.id, sess[0]!.id));

    // 5. Read back and assert.
    const refreshed = await db.select().from(session).where(eq(session.id, sess[0]!.id));
    if (refreshed[0]!.activeOrganizationId !== teamOrgId) {
      throw new Error(
        `Switch failed: got ${refreshed[0]!.activeOrganizationId}, expected ${teamOrgId}`,
      );
    }
    console.log(`✓ Switch persisted: session.activeOrganizationId = ${teamOrgId}`);
  } finally {
    // Cleanup: delete the user (cascades member). Then sweep any orphan
    // smoke orgs (Personal-Org-of-smoke or Team-Smoke-* with no members).
    await db.delete(user).where(eq(user.id, userId));
    await db
      .delete(organization)
      .where(
        and(
          like(organization.name, "Personal — smoke-switch-%"),
          notExists(
            db
              .select({ one: member.id })
              .from(member)
              .where(eq(member.organizationId, organization.id)),
          ),
        ),
      );
    await db
      .delete(organization)
      .where(
        and(
          like(organization.name, "Team Smoke %"),
          notExists(
            db
              .select({ one: member.id })
              .from(member)
              .where(eq(member.organizationId, organization.id)),
          ),
        ),
      );
    console.log(`→ Cleanup done`);
  }

  console.log(`✅ verify-org-switch OK`);
  process.exit(0);
}

main().catch((e) => {
  console.error(`❌ verify-org-switch FAILED:`, e instanceof Error ? e.message : e);
  process.exit(1);
});
```

- [ ] **Step 7.2: Register in `package.json`**

Add right after `verify:org-sites`:

```json
"verify:org-switch": "tsx --env-file-if-exists=.env.local --env-file-if-exists=.env scripts/verify-org-switch.ts",
```

- [ ] **Step 7.3: Run + verify cleanup**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm verify:org-switch
PGPASSWORD="$(grep DATABASE_URL .env.local | sed -E 's|.*://[^:]+:([^@]+)@.*|\1|')" psql -h 127.0.0.1 -p 5432 -U elevaspeed -d elevaspeed -c \
  "SELECT COUNT(*) FROM \"user\" WHERE email LIKE 'smoke-switch-%';"
```

Don't print the password. Expected: smoke test ends with `✅ verify-org-switch OK` and cleanup count is `0`.

- [ ] **Step 7.4: Commit**

```bash
git add scripts/verify-org-switch.ts package.json
git commit -m "test: add smoke test for org switching (verify-org-switch)

Signs up a fresh user (gets Personal Org via Phase 1 hook), creates a
second org + admin membership directly via DB, updates the session's
activeOrganizationId to the new org, asserts the switch persisted, and
cleans up both orgs in finally{}. Validates the data model the
<OrgSwitcher> + switchActiveOrganization Server Action will exercise."
```

---

## Task 8: Final validation + manual E2E

- [ ] **Step 8.1: Full validation**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm typecheck
pnpm lint
pnpm verify:org-signup
pnpm verify:org-sites
pnpm verify:org-switch
```

All must pass.

- [ ] **Step 8.2: Manual E2E browser check**

Tell the user to run `! pnpm dev:agent` and:

1. Log in as `bastien@elevaseo.com` / `Eleva2025!`.
2. Verify the new `<OrgSwitcher>` appears at the top of the sidebar showing "Personal — bastien@elevaseo.com".
3. Click the switcher → confirm dropdown shows the org with a check + "+ Create new organization" item.
4. Click "+ Create new organization" → land on `/onboarding`.
5. Submit the form with a name like "Test Workspace" → should redirect to `/dashboard` with the new org active in the switcher.
6. Click switcher → switch back to the Personal Org → dashboard re-renders showing the Personal Org's sites.
7. Log out, log in as `admin-test@elevaseo.com` / `AdminTest!2026`. Switcher should show **2 orgs** (their Personal Org + owner-test's team org).

(The `/accept-invitation/[id]/` page can't be tested manually in Phase 3 since invitations aren't generated yet — Phase 4 will wire that.)

- [ ] **Step 8.3: Format pass on touched files**

```bash
cd /Users/humperdoo/Github/eleva-speed
pnpm format
pnpm format:check 2>&1 | tail -3
```

If there's debt, commit a `chore(format)` separately.

- [ ] **Step 8.4: Log commits**

```bash
git log --oneline dev..HEAD
```

Expected: ~8 commits, all `feat(...)`, `i18n(...)`, or `test(...)`.

---

## Critères d'acceptation Phase 3

- ✅ `lib/actions/organization.ts` exports `switchActiveOrganization`, `createOrganization`, `acceptInvitation`, `rejectInvitation`.
- ✅ `lib/data/organization.ts` exports `listUserOrganizations(userId)`.
- ✅ `<OrgSwitcher>` rendered in the dashboard sidebar, lists user's memberships + "Create new" item.
- ✅ `/onboarding` page lets the user create a new org OR switch to an existing one.
- ✅ `/accept-invitation/[id]` page exists with Accept/Reject actions (cannot be fully manually tested until Phase 4 generates invitations).
- ✅ `loading.tsx` + `error.tsx` (+ `not-found.tsx` for accept-invitation) cover the new segments.
- ✅ i18n: namespaces `Org.switcher`, `Onboarding`, `Invitation` added to EN/FR/ES with key parity.
- ✅ Smoke test `verify:org-switch` PASS.
- ✅ Manual E2E: bastien can create a new org via `/onboarding` and switch back via the switcher.
- ✅ `pnpm typecheck && pnpm lint` clean.

## Next phase

Une fois Phase 3 mergée dans `dev`, on rédige le plan Phase 4 : **`/settings/profile`, `/settings/organization`, `/settings/team` + RBAC enforcement complet + email d'invitation via Resend**.
