# Auth / Session MVP (Better Auth) — Design

**Date:** 2026-06-05
**Status:** Spec for review.
**Why now:** Prerequisite for the GSC bridge (P2). The GSC import needs the logged-in user's Google access token (stored by Better Auth in `account`), and a real session/org instead of the hardcoded `demo-org`/`seed-owner`.

This is **project 1 of 2**. P2 (GSC bridge) is a separate spec that builds on the session + Google token delivered here.

## Decisions (locked with user)
- **Login = app access**, via Better Auth. Methods: **Google** (which also carries the GSC scope) **+ magic-link/email** (already configured).
- **One organization per user**: on first sign-in, auto-create a fresh org, add the user as `owner`, set it active. New users start on an **empty** app (they create their own sites via the existing projects management) — the seeded `demo-org` data is not shared with them.
- **Org-scoped data sharing is inherent**: all data is keyed by `orgId`, so teammates **invited** into an org (Better Auth `member`/`invitation`) see that org's data automatically; only a brand-new owner's org is empty until they add sites. The invitation **UI/flow** is deferred (see out of scope), but no extra data plumbing is needed for sharing — it follows from org scoping.
- Google OAuth requests the **GSC scope now** (`webmasters.readonly`) with offline access so P2 can call the API without a second consent.

## Current state (verified)
- `src/lib/auth.ts`: Better Auth with `emailAndPassword`, `organization` plugin (roles owner/admin/staff via `./permissions`), `magicLink` (dev stub logs the link). **No social providers.**
- `src/lib/auth-client.ts`: client with `organizationClient` + `magicLinkClient`.
- `src/lib/db/auth-schema.ts`: tables `user, session, account, verification, organization, member, invitation` already exist. `account` already has `accessToken/refreshToken/idToken/accessTokenExpiresAt/refreshTokenExpiresAt/scope/providerId`. `session` has `activeOrganizationId`.
- `src/lib/active-org.ts`: `getActiveOrgId()` → `"demo-org"`, `getActiveUserId()` → `"seed-owner"` (hardcoded, with TODO).
- `src/env.ts`: Zod schema with only `DATABASE_URL`. `BETTER_AUTH_SECRET`/`BETTER_AUTH_URL` read by better-auth from env.
- No `src/middleware.ts`/proxy, no `/login` route.
- `better-auth@^1.6.14`. Next 16 (⚠️ read `node_modules/next/dist/docs/` for the current middleware convention before coding).

## Architecture / components

### 1. Env (`src/env.ts`)
Add **optional** Google creds so the app still boots without them (magic-link path remains testable; Google button disabled/hidden when unset):
```ts
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
```
Expose a derived `googleEnabled = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET)`.

### 2. Better Auth server (`src/lib/auth.ts`)
- Add `socialProviders.google` **only when** creds are present:
  ```ts
  socialProviders: env.googleEnabled ? {
    google: {
      clientId: env.GOOGLE_CLIENT_ID!,
      clientSecret: env.GOOGLE_CLIENT_SECRET!,
      scope: ["https://www.googleapis.com/auth/webmasters.readonly"],
      accessType: "offline",
      prompt: "consent",            // ensures a refresh_token is returned
    },
  } : undefined,
  ```
  (Confirm exact option names/shape against Better Auth 1.6 docs — `scope`, `accessType`, `prompt`.)
- **Org-per-user on signup** via `databaseHooks.user.create.after`: create an `organization` (id = generated, name = user.name || user.email, unique slug), insert a `member` row (role `owner`) linking user↔org. Set it active for the new session.
  - Setting active org: prefer Better Auth's mechanism (organization plugin `setActive`, or a `databaseHooks.session.create.before` that fills `activeOrganizationId` with the user's single org). Finalize the exact hook against 1.6 docs; the **observable requirement**: right after first login, `session.activeOrganizationId` is the user's new org.
- Keep `emailAndPassword` + `magicLink` + `organization` as-is.

### 3. Active-org/user from session (`src/lib/active-org.ts`)
Replace the hardcoded returns with session reads:
```ts
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) throw new Error("Not authenticated");
  return session.user.id;
}
```
These are only called from request-scoped server code (the `(app)` layout, page loaders, server actions), which will be behind the middleware gate, so a session is guaranteed. (Confirm `auth.api.getSession` signature in 1.6.)

### 4. Route protection (middleware / Next 16 proxy)
Add request-level protection for the `(app)` route group: unauthenticated requests → redirect to `/login`. Use a **cookie-presence** check (Better Auth `getSessionCookie`/cookie name) for edge speed, not a full DB session lookup. ⚠️ Next 16 changed the middleware convention — read `node_modules/next/dist/docs/` first and use whatever Next 16 mandates (`middleware.ts` vs `proxy.ts`). Public paths: `/login`, `/api/auth/*`, static assets.

### 5. Login page — adapt the already-added shadcn **login-04** block
The shadcn `login-04` block is already on disk (added via `npx shadcn add login-04`): `src/app/login/page.tsx`, `src/components/login-form.tsx`, plus new primitives `src/components/ui/label.tsx` and `src/components/ui/field.tsx`. The page lives at `/login` — OUTSIDE the `(app)` group, so it is not behind the app gate. **Commit these added files as part of P1.**

`login-form.tsx` ships as a generic template (Acme copy, email+password, Apple/Google/Meta buttons, split image, Sign-up/Forgot links). **Adapt it to Better Auth + this app** (make it a client component, `"use client"`):
- Remove the **Apple** and **Meta** buttons; keep **Google**. Wire Google → `authClient.signIn.social({ provider: "google", callbackURL: "/keywords/reservoir" })`. Render the Google button **only when `googleEnabled`** (pass a boolean prop from a server wrapper reading `env.googleEnabled`).
- Wire the **email + password** form to `authClient.signIn.email({ email, password, callbackURL: "/keywords/reservoir" })` (emailAndPassword is enabled). Surface a sign-in error inline.
- Keep a **magic-link** affordance (the plugin is configured): a secondary "Recevoir un lien magique" action → `authClient.signIn.magicLink({ email, callbackURL: "/keywords/reservoir" })`; show "lien envoyé (voir la console en dev)".
- Replace the "Acme Inc"/"Welcome back" copy with the app's name; drop or repoint the placeholder image, "Sign up", "Forgot password", and Terms/Privacy links (no targets in MVP — remove or `#` with a TODO). Keep it clean and on-brand (dark theme).
- On success the user lands in the app; a brand-new user gets their org via the create hook.

### 6. Logout + identity (minimal)
`src/components/nav-user.tsx` already renders a user area in the sidebar. Wire a **Déconnexion** action → `authClient.signOut()` → redirect `/login`, and show the real user's name/email from the session (passed down from the `(app)` layout which already loads server data). Keep minimal — no full account settings.

## Data flow
```
Browser /login → authClient.signIn (google | magicLink)
  → Better Auth handles OAuth/magic-link → creates user (first time)
  → databaseHooks.user.create.after: create org + member(owner), set active
  → session cookie set; Google access/refresh tokens stored in `account`
  → redirect into (app); middleware allows (cookie present)
  → (app) layout + loaders call getActiveOrgId()/getActiveUserId() → read session
```

## Error handling
- Missing Google creds → Google button hidden; magic-link still works; no crash (creds optional in env).
- `getActiveOrgId/UserId` with no session → throw (should not happen behind middleware; surfaces a clear error if a protected loader is hit without auth).
- Org hook failure → surfaced at signup; user not left orgless (wrap create org+member so a failure aborts cleanly / is logged).

## Testing
- **DB test** (gated, `*_test`): a test that simulates "first sign-in creates an org + owner member" — call the org-creation helper (extract the org-creation logic into a testable `createOrgForUser(userId, name)` in e.g. `src/lib/auth/org-bootstrap.ts`, called by the hook) and assert an `organization` + `member(owner)` row exist for the user. (Hooks themselves are hard to unit-test; testing the extracted helper is the seam.)
- **Manual**: magic-link login (no Google creds needed) → lands in app with a fresh org → reservoir/research are empty → create a site via projects management → it appears. Logout → redirected to /login. Protected route while logged out → redirected to /login.
- **Google path**: requires the user's Google Cloud creds + test users (see prerequisites); verified manually once creds are set.
- Existing keyword/backlink/db DB tests must stay green (they use explicit ids, not the active-org helpers).

## Out of scope (this spec)
- The GSC bridge itself (P2).
- Email provider for magic-link (stays the console stub).
- Account settings / org switching UI / invitations flow / roles management UI.
- Migrating the seeded `demo-org` data to real users (new users start empty by decision).

## External prerequisites (user must provide)
Google login can't work end-to-end without these (magic-link path works without them):
1. **Google Cloud project** → APIs & Services → **OAuth consent screen**: User type *External*, publishing status **Testing**, add your email(s) as **Test users** (avoids the sensitive-scope verification requirement during dev).
2. Add scope `…/auth/webmasters.readonly` to the consent screen.
3. Enable the **Google Search Console API** (needed for P2).
4. **Credentials** → OAuth client ID (type *Web application*). Authorized redirect URI: `<BETTER_AUTH_URL>/api/auth/callback/google` (e.g. `http://localhost:3000/api/auth/callback/google`).
5. Put `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` in `.env` (+ `.env.test` not needed). Ensure `BETTER_AUTH_URL` and `BETTER_AUTH_SECRET` are set.

## Sequencing note
P2 (GSC bridge) starts after this merges: it adds `SourceMetrics` GSC fields, a per-site `gscProperty`, `importFromGscAction` (reads the Google token from `account`, refreshes via Google token endpoint, calls `searchanalytics.query`), and the "Importer depuis GSC" button.
