# Auth / Session MVP (Better Auth) — 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:** Replace the hardcoded `demo-org`/`seed-owner` with a real Better Auth session — Google login (carrying the GSC scope) + email/password + magic-link, one org auto-created per user, `(app)` routes protected — as the prerequisite for the GSC bridge (P2).

**Architecture:** Extend the existing Better Auth instance (`src/lib/auth.ts`) with a gated Google social provider and `databaseHooks` that create an org + owner membership on first sign-in and set it active on the session. `getActiveOrgId/UserId` read the session. A Next 16 `proxy.ts` (the renamed "middleware") gates the app via a session-cookie check. The login UI adapts the already-added shadcn `login-04` block.

**Tech Stack:** Next 16 (App Router, **`proxy.ts`** not `middleware.ts`), Better Auth 1.6 (Drizzle adapter, organization + magicLink plugins), Postgres/Drizzle, bun test, biome.

**Spec:** `docs/superpowers/specs/2026-06-05-auth-session-mvp-design.md`

**Canonical references (consult these, not memory):**
- Installed skills: **`better-auth-best-practices`** and **`organization-best-practices`** (in `.claude/skills/`) — confirmed: `socialProviders.google: { clientId, clientSecret }`, `databaseHooks.user.create.after`, client `signUp.email`/`signIn.email`/`signIn.social`/`signOut`.
- Official **Better Auth Next.js example** repo (`github.com/better-auth/better-auth`, `demo/nextjs`) — verified: google provider shape `{ clientId, clientSecret }`, plugins imported from the `better-auth/plugins` barrel (so the project's existing import style is correct). The example's middleware is the old `middleware.ts`; **we use Next 16 `proxy.ts` instead** (see below).
- `better-auth.com/docs` + `better-auth.com/llms.txt` for any exact option not covered.

**Already verified in the installed packages:**
- `getSessionCookie` is exported from `better-auth/cookies`.
- Next 16 renamed middleware → **`proxy.ts`** (project root or `src/`, export `proxy` or default, `config.matcher`) — `node_modules/next/dist/docs/01-app/01-getting-started/16-proxy.md`.
- The `account` table already has OAuth token columns → **no schema migration is needed** to add Google (do NOT run `@better-auth/cli generate`).
- `organization`/`member`/`invitation` tables already exist (org plugin schema present).

**Quick-confirm at implementation (1 line each, against installed `.d.mts`):** the exact key names for the Google extra options (`scope`/`accessType`/`prompt`) and the `databaseHooks.session.create.before` return contract (`{ data }`). Keep the specified behavior; adjust only the spelling if the installed types differ.

**DB test command (gated, *_test DB):**
`DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test <file>`

---

## File structure

| File | Responsibility | Action |
|---|---|---|
| `src/env.ts` | env schema | Modify — add optional `GOOGLE_CLIENT_ID/SECRET` + `googleEnabled` |
| `src/lib/auth/org-bootstrap.ts` | create org+owner for a user | Create |
| `src/lib/auth/org-bootstrap.test.ts` | DB test for the helper | Create |
| `src/lib/auth.ts` | Better Auth server | Modify — google provider + hooks |
| `src/lib/active-org.ts` | active org/user from session | Modify |
| `proxy.ts` (project root) | route protection | Create |
| `src/components/login-form.tsx` | login UI | Modify — wire Better Auth |
| `src/app/login/page.tsx`, `src/components/ui/{label,field}.tsx` | added shadcn block | Commit as-is (+ page passes `googleEnabled`) |
| `src/components/signup-form.tsx` | signup UI | Modify — wire Better Auth |
| `src/app/signup/page.tsx` | added shadcn signup-04 block | Commit as-is (+ page passes `googleEnabled`) |
| `src/components/nav-user.tsx` | logout + identity | Modify |

---

## Task 1: Env — optional Google creds

**Files:** Modify `src/env.ts`

- [ ] **Step 1: Read current env.ts**, then replace its schema/export with:
```ts
// src/env.ts
import { z } from "zod";

const schema = z.object({
  DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
  GOOGLE_CLIENT_ID: z.string().optional(),
  GOOGLE_CLIENT_SECRET: z.string().optional(),
});

const parsed = schema.parse(process.env);

export const env = {
  ...parsed,
  /** Google social login is available only when both creds are set. */
  googleEnabled: Boolean(parsed.GOOGLE_CLIENT_ID && parsed.GOOGLE_CLIENT_SECRET),
};
```
(Keep the existing import style — if the file already imports `z` differently, match it.)

- [ ] **Step 2: Verify it boots without Google creds**

Run: `bunx tsc --noEmit`
Expected: 0 errors. (No creds in `.env` is fine — `googleEnabled` is just `false`.)

- [ ] **Step 3: Commit**
```bash
git add src/env.ts
git commit -m "feat(auth): optional GOOGLE_CLIENT_ID/SECRET env + googleEnabled flag"
```

---

## Task 2: Org-bootstrap helper (+ DB test)

**Files:** Create `src/lib/auth/org-bootstrap.ts`, `src/lib/auth/org-bootstrap.test.ts`

- [ ] **Step 1: Write the failing test** — `src/lib/auth/org-bootstrap.test.ts`:
```ts
// Integration test — gated on DB_TESTS=1, run against the *_test DB only.
import { beforeEach, describe, expect, mock, test } from "bun:test";

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

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

describe.skipIf(!RUN_DB)("createOrgForUser (postgres)", () => {
  let db: typeof import("@/lib/db/client").db;
  let schema: typeof import("@/lib/db/auth-schema");
  let mod: typeof import("./org-bootstrap");
  const USER = "test-bootstrap-user";

  beforeEach(async () => {
    ({ db } = await import("@/lib/db/client"));
    schema = await import("@/lib/db/auth-schema");
    mod = await import("./org-bootstrap");
    const { assertTestDb } = await import("@/lib/db/assert-test-db");
    const { eq } = await import("drizzle-orm");
    await assertTestDb();
    // Clean only this test's rows (members → orgs of this user → the user).
    const members = await db
      .select({ orgId: schema.member.organizationId })
      .from(schema.member)
      .where(eq(schema.member.userId, USER));
    await db.delete(schema.member).where(eq(schema.member.userId, USER));
    for (const m of members) {
      await db.delete(schema.organization).where(eq(schema.organization.id, m.orgId));
    }
    await db.delete(schema.user).where(eq(schema.user.id, USER));
    await db.insert(schema.user).values({
      id: USER,
      name: "Test Bootstrap",
      email: "bootstrap@test.dev",
      emailVerified: true,
      createdAt: new Date(),
      updatedAt: new Date(),
    });
  });

  test("creates an organization + owner membership, returns the org id", async () => {
    const orgId = await mod.createOrgForUser(USER, "Test Bootstrap");
    const { eq } = await import("drizzle-orm");
    const [org] = await db
      .select()
      .from(schema.organization)
      .where(eq(schema.organization.id, orgId));
    expect(org).toBeDefined();
    const [m] = await db
      .select()
      .from(schema.member)
      .where(eq(schema.member.userId, USER));
    expect(m.organizationId).toBe(orgId);
    expect(m.role).toBe("owner");
  });

  test("slugs are unique across calls (no collision)", async () => {
    const a = await mod.createOrgForUser(USER, "Acme");
    const b = await mod.createOrgForUser(USER, "Acme");
    expect(a).not.toBe(b);
  });
});
```
NOTE: confirm the `user` table's required columns from `src/lib/db/auth-schema.ts` (the insert above assumes `id,name,email,emailVerified,createdAt,updatedAt`); adjust the seed insert to match the actual non-null columns.

- [ ] **Step 2: Run it → confirm FAIL** (`createOrgForUser is not a function`):

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/auth/org-bootstrap.test.ts`
Expected: FAIL.

- [ ] **Step 3: Implement** — `src/lib/auth/org-bootstrap.ts`:
```ts
// src/lib/auth/org-bootstrap.ts — create a personal org + owner membership.
import "server-only";
import { db } from "@/lib/db/client";
import { member, organization } from "@/lib/db/auth-schema";

function slugify(name: string): string {
  return (
    name
      .toLowerCase()
      .normalize("NFD")
      .replace(/\p{Diacritic}/gu, "")
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/(^-|-$)/g, "") || "org"
  );
}

/** Create a fresh organization owned by `userId`; returns the new org id. */
export async function createOrgForUser(
  userId: string,
  name: string,
): Promise<string> {
  const orgId = crypto.randomUUID();
  const slug = `${slugify(name)}-${orgId.slice(0, 8)}`;
  const now = new Date();
  await db.insert(organization).values({
    id: orgId,
    name: name || "Mon organisation",
    slug,
    createdAt: now,
  });
  await db.insert(member).values({
    id: crypto.randomUUID(),
    organizationId: orgId,
    userId,
    role: "owner",
    createdAt: now,
  });
  return orgId;
}
```

- [ ] **Step 4: Run it → confirm PASS (2 tests)**

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/auth/org-bootstrap.test.ts`
Expected: 2 pass.

- [ ] **Step 5: Commit**
```bash
git add src/lib/auth/org-bootstrap.ts src/lib/auth/org-bootstrap.test.ts
git commit -m "feat(auth): createOrgForUser helper (org + owner membership)"
```

---

## Task 3: Wire Better Auth — Google provider + org-on-signup + active org

**Files:** Modify `src/lib/auth.ts`

- [ ] **Step 1: Update `src/lib/auth.ts`** to add the gated Google provider and the two hooks. Replace the `betterAuth({...})` call with:
```ts
// src/lib/auth.ts — Better Auth server instance.
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { magicLink, organization } from "better-auth/plugins";
import { eq } from "drizzle-orm";
import * as authSchema from "./db/auth-schema";
import { db } from "./db/client";
import { env } from "@/env";
import { createOrgForUser } from "./auth/org-bootstrap";
import { ac, admin, owner, staff } from "./permissions";

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),
  emailAndPassword: { enabled: true },
  socialProviders: env.googleEnabled
    ? {
        google: {
          clientId: env.GOOGLE_CLIENT_ID as string,
          clientSecret: env.GOOGLE_CLIENT_SECRET as string,
          scope: ["https://www.googleapis.com/auth/webmasters.readonly"],
          accessType: "offline",
          prompt: "consent",
        },
      }
    : undefined,
  databaseHooks: {
    user: {
      create: {
        after: async (user) => {
          await createOrgForUser(user.id, user.name ?? user.email);
        },
      },
    },
    session: {
      create: {
        before: async (session) => {
          const [m] = await db
            .select({ orgId: authSchema.member.organizationId })
            .from(authSchema.member)
            .where(eq(authSchema.member.userId, session.userId))
            .limit(1);
          return m
            ? { data: { ...session, activeOrganizationId: m.orgId } }
            : { data: session };
        },
      },
    },
  },
  plugins: [
    organization({ ac, roles: { owner, admin, staff } }),
    magicLink({
      sendMagicLink: async ({ email, url }) => {
        console.log(`[magic-link] ${email} -> ${url}`);
      },
    }),
  ],
});
```
**Confirm before trusting:** (a) the Google option keys `scope`/`accessType`/`prompt` against `node_modules/better-auth/dist/social-providers/` types; (b) `databaseHooks.user.create.after(user)` and `databaseHooks.session.create.before(session)` signatures + the `{ data }` return contract against `node_modules/better-auth/dist/db/with-hooks.d.mts`; (c) that `session.userId` is the field name on the session insert object. Adjust to match the installed types if they differ (keep the same behavior: create org+owner after user create; set `activeOrganizationId` to the user's membership on session create).

- [ ] **Step 2: Typecheck**

Run: `bunx tsc --noEmit`
Expected: 0 errors. If a hook signature mismatches, fix per the installed `.d.mts`.

- [ ] **Step 3: Verify the org-bootstrap test still passes** (no regression):

Run: `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/auth/org-bootstrap.test.ts`
Expected: 2 pass.

- [ ] **Step 4: Commit**
```bash
git add src/lib/auth.ts
git commit -m "feat(auth): gated Google provider + auto org-per-user + active org on session"
```

---

## Task 4: Read active org/user from the session

**Files:** Modify `src/lib/active-org.ts`

- [ ] **Step 1: Replace `src/lib/active-org.ts`** with session reads:
```ts
// src/lib/active-org.ts — resolves the current org/user from the Better Auth session.
import { headers } from "next/headers";
import { auth } from "./auth";

export async function getActiveOrgId(): Promise<string> {
  const session = await auth.api.getSession({ headers: await headers() });
  const orgId = session?.session.activeOrganizationId;
  if (!orgId) throw new Error("No active organization in session");
  return orgId;
}

export async function getActiveUserId(): Promise<string> {
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session?.user) throw new Error("Not authenticated");
  return session.user.id;
}
```
**Confirm:** `auth.api.getSession({ headers })` returns `{ session: { activeOrganizationId, ... }, user: { id, ... } } | null` (check `node_modules/better-auth/dist/api/` or docs). Adjust field access if the shape differs.

- [ ] **Step 2: Typecheck**

Run: `bunx tsc --noEmit`
Expected: 0 errors.

- [ ] **Step 3: Commit**
```bash
git add src/lib/active-org.ts
git commit -m "feat(auth): resolve active org/user from session"
```

---

## Task 5: Route protection — Next 16 `proxy.ts`

**Files:** Create `proxy.ts` at the **project root** (same level as `src/`).

- [ ] **Step 1: Create `proxy.ts`**:
```ts
// proxy.ts — Next 16 "proxy" (formerly middleware). Optimistic auth gate:
// redirect to /login when no session cookie is present. Real validation
// happens server-side via getActiveOrgId/getActiveUserId.
import { NextResponse, type NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export function proxy(request: NextRequest) {
  const cookie = getSessionCookie(request);
  if (!cookie) {
    return NextResponse.redirect(new URL("/login", request.url));
  }
  return NextResponse.next();
}

export const config = {
  // Run on everything EXCEPT login/signup, the auth API, and Next internals.
  matcher: ["/((?!login|signup|api/auth|_next/static|_next/image|favicon.ico).*)"],
};
```
**Confirm:** `getSessionCookie(request)` accepts a `NextRequest` in 1.6 (check `node_modules/better-auth/dist/cookies/index.d.mts`); and that Next 16 picks up `proxy.ts` (per `16-proxy.md`). If the project keeps source under `src/`, place `proxy.ts` at the repo root next to `src/` (the doc allows root or `src/`).

- [ ] **Step 2: Manual verify (dev server)**

Run: `bun dev`, then in a logged-out browser open `http://localhost:3000/keywords/reservoir`.
Expected: redirected to `/login`. Opening `/login` directly: no redirect loop.

- [ ] **Step 3: Commit**
```bash
git add proxy.ts
git commit -m "feat(auth): protect app routes via Next 16 proxy (session-cookie gate)"
```

---

## Task 6: Adapt the login UI (shadcn login-04) to Better Auth

**Files:** Modify `src/components/login-form.tsx`; commit the added `src/app/login/page.tsx`, `src/components/ui/label.tsx`, `src/components/ui/field.tsx`. Modify `src/app/login/page.tsx` to pass `googleEnabled`.

- [ ] **Step 1: Make `login/page.tsx` pass the Google flag** (server component reads env):
```tsx
// src/app/login/page.tsx
import { LoginForm } from "@/components/login-form";
import { env } from "@/env";

export default function LoginPage() {
  return (
    <div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
      <div className="w-full max-w-sm">
        <LoginForm googleEnabled={env.googleEnabled} />
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Rewrite `src/components/login-form.tsx`** as a client component wired to `authClient` (drop Apple/Meta, drop the split image, keep Google + email/password + magic-link). Replace the whole file:
```tsx
// src/components/login-form.tsx
"use client";

import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

const AFTER_LOGIN = "/keywords/reservoir";

export function LoginForm({
  googleEnabled,
  className,
  ...props
}: React.ComponentProps<"div"> & { googleEnabled?: boolean }) {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [magicSent, setMagicSent] = useState(false);

  async function signInPassword(e: React.FormEvent) {
    e.preventDefault();
    setPending(true);
    setError(null);
    const res = await authClient.signIn.email({
      email,
      password,
      callbackURL: AFTER_LOGIN,
    });
    setPending(false);
    if (res.error) setError(res.error.message ?? "Échec de la connexion.");
  }

  async function sendMagic() {
    setPending(true);
    setError(null);
    const res = await authClient.signIn.magicLink({
      email,
      callbackURL: AFTER_LOGIN,
    });
    setPending(false);
    if (res.error) setError(res.error.message ?? "Échec de l'envoi du lien.");
    else setMagicSent(true);
  }

  return (
    <div className={cn("flex flex-col gap-6", className)} {...props}>
      <Card>
        <CardContent className="p-6 md:p-8">
          <form onSubmit={signInPassword}>
            <FieldGroup>
              <div className="flex flex-col items-center gap-2 text-center">
                <h1 className="font-bold text-2xl">SEO Insight</h1>
                <p className="text-balance text-muted-foreground">
                  Connecte-toi à ton espace
                </p>
              </div>
              <Field>
                <FieldLabel htmlFor="email">Email</FieldLabel>
                <Input
                  id="email"
                  type="email"
                  placeholder="m@exemple.com"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                  required
                />
              </Field>
              <Field>
                <FieldLabel htmlFor="password">Mot de passe</FieldLabel>
                <Input
                  id="password"
                  type="password"
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                />
              </Field>
              {error ? (
                <p className="text-destructive text-sm" role="alert">
                  {error}
                </p>
              ) : null}
              {magicSent ? (
                <p className="text-muted-foreground text-sm">
                  Lien magique envoyé — vois la console en dev.
                </p>
              ) : null}
              <Field>
                <Button type="submit" disabled={pending || !email}>
                  {pending ? "…" : "Se connecter"}
                </Button>
              </Field>
              <Field>
                <Button
                  type="button"
                  variant="outline"
                  onClick={sendMagic}
                  disabled={pending || !email}
                >
                  Recevoir un lien magique
                </Button>
              </Field>
              {googleEnabled ? (
                <>
                  <FieldSeparator>ou</FieldSeparator>
                  <Field>
                    <Button
                      type="button"
                      variant="outline"
                      onClick={() =>
                        authClient.signIn.social({
                          provider: "google",
                          callbackURL: AFTER_LOGIN,
                        })
                      }
                    >
                      Continuer avec Google
                    </Button>
                  </Field>
                </>
              ) : null}
              <FieldDescription className="text-center">
                Accès sur invitation.
              </FieldDescription>
            </FieldGroup>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}
```
**Confirm:** `authClient.signIn.email`, `.magicLink`, `.social` shapes + the `{ error }` result against `node_modules/better-auth` client types; adjust if the result is `{ data, error }` with a different error shape.

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

Run: `bunx tsc --noEmit` then `bunx biome check src/components/login-form.tsx src/app/login/page.tsx src/components/ui/label.tsx src/components/ui/field.tsx`
Expected: 0 tsc errors; biome clean on these files (let `--write` reformat the shadcn files if needed).

- [ ] **Step 4: Manual verify** (no Google creds needed): `bun dev` → `/login` → enter an email → "Recevoir un lien magique" → check the dev console logs the magic link → open it → lands in `/keywords/reservoir` with a fresh (empty) org.

- [ ] **Step 5: Commit** (include the shadcn-added files):
```bash
git add src/components/login-form.tsx src/app/login/page.tsx src/components/ui/label.tsx src/components/ui/field.tsx
git commit -m "feat(auth): wire login UI (Google + email/password + magic-link) on shadcn login-04"
```

---

## Task 7: Signup UI (shadcn signup-04)

**Files:** Modify `src/components/signup-form.tsx`; commit the added `src/app/signup/page.tsx`. The signup-04 block was already added via `npx shadcn add signup-04`; it reuses the same `ui/*` primitives as login.

- [ ] **Step 1: Make `signup/page.tsx` pass the Google flag** (server component):
```tsx
// src/app/signup/page.tsx
import { SignupForm } from "@/components/signup-form";
import { env } from "@/env";

export default function SignupPage() {
  return (
    <div className="flex min-h-svh flex-col items-center justify-center bg-muted p-6 md:p-10">
      <div className="w-full max-w-sm">
        <SignupForm googleEnabled={env.googleEnabled} />
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Rewrite `src/components/signup-form.tsx`** as a client component wired to Better Auth (`signUp.email` + Google), mirroring the login form (drop Apple/Meta, clean placeholders). Replace the whole file:
```tsx
// src/components/signup-form.tsx
"use client";

import Link from "next/link";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
  Field,
  FieldDescription,
  FieldGroup,
  FieldLabel,
  FieldSeparator,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

const AFTER_SIGNUP = "/keywords/reservoir";

export function SignupForm({
  googleEnabled,
  className,
  ...props
}: React.ComponentProps<"div"> & { googleEnabled?: boolean }) {
  const [name, setName] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [pending, setPending] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function submit(e: React.FormEvent) {
    e.preventDefault();
    setPending(true);
    setError(null);
    const res = await authClient.signUp.email({
      name,
      email,
      password,
      callbackURL: AFTER_SIGNUP,
    });
    setPending(false);
    if (res.error) setError(res.error.message ?? "Échec de l'inscription.");
  }

  return (
    <div className={cn("flex flex-col gap-6", className)} {...props}>
      <Card>
        <CardContent className="p-6 md:p-8">
          <form onSubmit={submit}>
            <FieldGroup>
              <div className="flex flex-col items-center gap-2 text-center">
                <h1 className="font-bold text-2xl">Créer un compte</h1>
                <p className="text-balance text-muted-foreground">
                  Démarre ton espace SEO Insight
                </p>
              </div>
              <Field>
                <FieldLabel htmlFor="name">Nom</FieldLabel>
                <Input
                  id="name"
                  value={name}
                  onChange={(e) => setName(e.target.value)}
                  required
                />
              </Field>
              <Field>
                <FieldLabel htmlFor="email">Email</FieldLabel>
                <Input
                  id="email"
                  type="email"
                  placeholder="m@exemple.com"
                  value={email}
                  onChange={(e) => setEmail(e.target.value)}
                  required
                />
              </Field>
              <Field>
                <FieldLabel htmlFor="password">Mot de passe</FieldLabel>
                <Input
                  id="password"
                  type="password"
                  value={password}
                  onChange={(e) => setPassword(e.target.value)}
                  required
                />
              </Field>
              {error ? (
                <p className="text-destructive text-sm" role="alert">
                  {error}
                </p>
              ) : null}
              <Field>
                <Button type="submit" disabled={pending || !email || !password}>
                  {pending ? "…" : "Créer le compte"}
                </Button>
              </Field>
              {googleEnabled ? (
                <>
                  <FieldSeparator>ou</FieldSeparator>
                  <Field>
                    <Button
                      type="button"
                      variant="outline"
                      onClick={() =>
                        authClient.signIn.social({
                          provider: "google",
                          callbackURL: AFTER_SIGNUP,
                        })
                      }
                    >
                      Continuer avec Google
                    </Button>
                  </Field>
                </>
              ) : null}
              <FieldDescription className="text-center">
                Déjà un compte ? <Link href="/login">Se connecter</Link>
              </FieldDescription>
            </FieldGroup>
          </form>
        </CardContent>
      </Card>
    </div>
  );
}
```
(`signUp.email` requires `name` + `email` + `password` — Better Auth registration needs `email` and `name`. Confirm the client result shape `{ error }` like the login form.)

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

Run: `bunx tsc --noEmit` then `bunx biome check src/components/signup-form.tsx src/app/signup/page.tsx`
Expected: 0 tsc errors; biome clean.

- [ ] **Step 4: Manual verify**: `/signup` → fill name/email/password → "Créer le compte" → a new user + a fresh org are created (org hook) → lands in `/keywords/reservoir` (empty). The `/login` "Se connecter" link works; `/signup` is reachable while logged out (proxy allows it).

- [ ] **Step 5: Commit**
```bash
git add src/components/signup-form.tsx src/app/signup/page.tsx
git commit -m "feat(auth): wire signup UI (email/password + Google) on shadcn signup-04"
```

---

## Task 8: Logout + identity in the sidebar

**Files:** Modify `src/components/nav-user.tsx`

- [ ] **Step 1: Read `src/components/nav-user.tsx`** to see how it currently renders the user block and where a menu/action can hook in.

- [ ] **Step 2: Add a logout action** using the client. In the user dropdown (or as a button), add:
```tsx
"use client";
import { authClient } from "@/lib/auth-client";
// ...
async function logout() {
  await authClient.signOut();
  window.location.href = "/login";
}
```
Wire it to a "Se déconnecter" menu item. If `nav-user.tsx` currently shows hardcoded user data, leave the display as-is for this MVP (identity wiring is optional) — the REQUIRED behavior is a working logout that lands on `/login`. If the component is a server component, either add a small client subcomponent for the logout button or convert minimally; do not over-refactor.

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

Run: `bunx tsc --noEmit` then `bunx biome check src/components/nav-user.tsx`
Expected: clean.

- [ ] **Step 4: Manual verify**: logged in → sidebar → "Se déconnecter" → redirected to `/login`; revisiting an app route → redirected to `/login`.

- [ ] **Step 5: Commit**
```bash
git add src/components/nav-user.tsx
git commit -m "feat(auth): sign-out action in sidebar"
```

---

## Final verification

- [ ] `bunx tsc --noEmit` → 0 errors.
- [ ] `bunx biome check` → no NEW issues in the files this plan touched (repo has pre-existing unrelated warnings).
- [ ] Per-dir DB tests still green:
  `DATABASE_URL=postgresql://humperdoo@localhost:5432/seo_insight_test DB_TESTS=1 bun test src/lib/auth` (org-bootstrap)
  and `… bun test src/lib/keywords` + `… bun test src/lib/backlink` (no regression — they use explicit ids, not the session helpers).
- [ ] Manual end-to-end (no Google creds): logged-out → any app route redirects to `/login`; magic-link sign-in (console link) creates a fresh org and lands in the app; logout returns to `/login`.
- [ ] Google path: documented in the spec's prerequisites; verify manually once `GOOGLE_CLIENT_ID/SECRET` + test users are configured (the Google button appears only then).
- [ ] Merge `feature/auth-session-mvp` → `dev` (ff-only), push. (Do NOT touch `main`.)

---

## Self-review notes
- **Spec coverage:** env+gating (T1), org-per-user helper+hooks (T2,T3), Google provider w/ GSC scope offline (T3), session-based active org/user (T4), route protection via Next 16 proxy (T5), login UI on login-04 (T6), signup UI on signup-04 (T7), logout (T8). Org-scoped sharing is inherent (no task needed — data already keyed by orgId). Invitation UI explicitly out of scope.
- **Version-sensitivity:** the Better-Auth-specific option/hook names are flagged per task with the exact `node_modules` path to confirm — the implementer adjusts to the installed types while preserving the specified behavior. This is intentional, not a placeholder.
- **Type consistency:** `createOrgForUser(userId, name): Promise<string>` defined in T2 is used by T3's `user.create.after`. `getActiveOrgId/UserId` (T4) consumed by existing loaders unchanged. `googleEnabled` (T1) flows env → auth.ts (T3) → login page → LoginForm (T6).
- **No data migration:** new users start empty by decision; seed `demo-org` remains for DB tests (which insert explicit ids and don't call the session helpers).
