<!--
Auth + Onboarding Playbook

Part of the free Claude Code template library from BILT AI — https://biltai.io/claude-templates

These are the actual playbooks used to build and ship production SaaS apps with
Claude Code. They're written to be handed to an agent as-is: point Claude at one
and it has the full pattern, the schema, the gotchas, and the "don't do this"
list. Paths, project refs, and org names have been replaced with placeholders
(<your-app>, <supabase-project-ref>, <your-github-org>) — search for "<" and
fill in your own before running.

Free to use, modify, and ship. No attribution required.
-->

# Auth + Onboarding Playbook

**Portable spec for shipping Supabase auth + signup flow + onboarding wizard + account-state gating in a new SaaS app.**

Built from the BKE / Kompozy implementation. Reference repo: `reference app`.

---

## 0. Use this spec

**Inputs:**
- `SUPABASE_URL` + `SUPABASE_ANON_KEY` + `SUPABASE_SERVICE_ROLE_KEY` from a new Supabase project
- Allowed OAuth providers (Google? Microsoft? GitHub? — recommend Google only at launch)
- App's primary auth flow: email/password? magic link? OAuth-only?
- Whether to require email confirmation (BKE has it OFF — see "Email confirmation gotcha")

**Outputs:**
- `/signup`, `/signin`, `/reset-password`, `/auth/callback`, `/onboarding/*` routes
- `proxy.ts` (Next.js 16) edge auth + admin gating
- Account-status enforcement (active / suspended / billing_paused / cancelled)
- Multi-step onboarding wizard with stage persistence
- Auth event observability (signup, signin, email-confirmed firing email enrollment hooks)

---

## 1. Architecture

```
src/
├── proxy.ts                          # Edge middleware (Next 16 renamed middleware.ts)
│                                     # Refreshes Supabase session every request
│                                     # Gates /dashboard, /admin, /api/*
│
├── lib/
│   ├── supabase.ts                   # Browser client via @supabase/ssr
│   ├── supabase-server.ts            # Server client + admin client (service role)
│   ├── user-auth.ts                  # requireUser() helper for API routes
│   ├── admin.ts                      # Admin role check + getEffectiveApiKeys()
│   └── admin-auth.ts                 # Admin-role RLS enforcement helpers
│
├── app/
│   ├── signin/page.tsx
│   ├── signup/page.tsx
│   ├── reset-password/page.tsx
│   ├── auth/callback/route.ts        # OAuth redirect target + email-confirm landing
│   ├── onboarding/
│   │   ├── layout.tsx                # Wizard chrome with progress indicator
│   │   ├── page.tsx                  # Step 1
│   │   ├── step-2/page.tsx
│   │   └── ...
│   └── dashboard/
│       └── layout.tsx                # Hydrates workspace + billing + settings
│                                      # Enforces account-status gate
```

---

## 2. Schema (Supabase)

```sql
-- profiles: 1-to-1 with auth.users
create table profiles (
  id uuid primary key references auth.users on delete cascade,
  email text not null,
  full_name text,
  company text,
  role text default 'user' check (role in ('user','admin')),
  account_status text default 'active' check (account_status in ('active','suspended','billing_paused','cancelled')),
  onboarding_completed_at timestamptz,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);

-- workspaces: 1-to-N from profiles
create table workspaces (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null references profiles(id) on delete cascade,
  name text not null,
  created_at timestamptz default now()
);

-- RLS
alter table profiles enable row level security;
create policy "users read own profile" on profiles for select using (auth.uid() = id);
create policy "users update own profile" on profiles for update using (auth.uid() = id);
create policy "admins read all" on profiles for select using (
  exists (select 1 from profiles p where p.id = auth.uid() and p.role = 'admin')
);
```

---

## 3. proxy.ts (Edge auth)

Reference: `src/proxy.ts`

**Required structure:**
- `PROTECTED_PREFIXES = ['/dashboard', '/onboarding']` — auth required
- `PUBLIC_AUTH_ROUTES = ['/signin', '/signup']` — auth-only, redirect to /dashboard if logged in
- `ADMIN_PROTECTED_PREFIXES = ['/admin/dashboard', '/api/admin']` — admin role required
- `PUBLIC_API_PREFIXES = []` — explicit allowlist; default-deny posture for `/api/*`
- Use `supabase.auth.getUser()` — NEVER `getSession()` (cookie-only, not verified)

**Default-deny for /api/***: every new API route is protected unless explicitly added to `PUBLIC_API_PREFIXES`. This is the most important hygiene rule.

---

## 4. Auto-create profile on signup

Use a Supabase database trigger so profiles are created in lockstep with auth.users:

```sql
create function handle_new_user() returns trigger
language plpgsql security definer set search_path = public
as $$
begin
  insert into profiles (id, email, full_name)
  values (new.id, new.email, coalesce(new.raw_user_meta_data->>'full_name', ''))
  on conflict (id) do nothing;
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function handle_new_user();
```

**Auto-enroll into trial-signup email campaigns** (paired with the email infra playbook): add a second trigger that enrolls every new profile in any active `email_campaigns` row where `trigger_event = 'trial_signup'`. BKE has this at `00068_enroll_trial_signup_on_profile_create.sql`.

---

## 5. Onboarding wizard pattern

**Multi-step persistent state.** Don't rely on URL params or localStorage — persist each step's payload to the user's profile or a dedicated `onboarding_state` table. Tab close / device switch must resume cleanly.

**Stage flow (typical):**
1. Welcome + company name → save to `profiles.company`
2. Use case selection → save to `profiles.use_case`
3. Workspace creation → insert `workspaces` row
4. Initial config (depends on app — Persona Brief, voice cloning, API keys, etc.)
5. Plan selection → redirect to Stripe checkout if paid plan
6. Done → set `profiles.onboarding_completed_at` + redirect to `/dashboard`

**Guard:** `dashboard/layout.tsx` checks `if (!profile.onboarding_completed_at) redirect('/onboarding/<current-step>')`.

---

## 6. Account status enforcement

`account_status` values:
- `active` — normal access
- `suspended` — admin-initiated; user sees suspension overlay on every page, no API access
- `billing_paused` — Stripe payment failed; soft block with "fix payment" CTA
- `cancelled` — opted out; read-only access for 30 days, then archive

**Enforcement layer:** dashboard/layout.tsx and every protected API route reads profile.account_status. If non-active, return a dedicated UI/response.

Reference: `src/lib/admin.ts::isAccountSuspended()` for the pattern.

---

## 7. Email confirmation gotcha (read before configuring)

**BKE has email confirmation DISABLED** in Supabase auth settings. If you enable confirmation:

1. Add `/auth/callback/route.ts` to handle the `?code=...` exchange.
2. Email enrollment triggers (trial-signup campaigns, etc.) must fire on **profile insert**, not on signup — because confirmation can take days and you don't want the "welcome" email arriving before they confirm.
3. The OAuth-only callback path differs from email-confirm callback — handle both.

If you keep confirmation disabled, the DB trigger from §4 handles everything. Simpler. Recommended for B2B/SaaS where the cold-email-list signal is the primary conversion event.

---

## 8. Common pitfalls

**❌ Don't parallelize `supabase.auth.getUser()` at boot.** Gotrue Web Lock serializes them anyway; competing for the lock at startup causes orphan-lock cascades on slow auth-endpoint moments. Hoist a single `getUser()` to the top of the dashboard hydrate path and pass the user down. (BKE lab note 2026-05-06.)

**❌ Don't use Supabase `getSession()` in proxy.ts.** It reads the cookie without revalidating with the auth server — a stale/revoked token still resolves. Always `getUser()`.

**❌ Don't wrap `try/catch` around `localStorage.setItem` that holds user-visible state.** Quota errors silently fail and the user loses data. (BKE lab note 2026-04-20.)

**❌ Don't write destructive sync routines.** Bulk-replace patterns (DELETE non-matching + UPSERT) have wiped user data twice on BKE. Sync is upsert-only. (BKE lab note 2026-04-23.)

**❌ Don't tighten Zod schemas on recovery-context fields.** Required fields turn missing-data into a 400, which is worse than the silent fallback recovery exists for. Keep workspaceId etc. `.optional()` on dispatch routes.

---

## 9. Kickoff prompt

> Execute the Auth + Onboarding Playbook at `<workspace>/templates/s-tier/AUTH-ONBOARDING-PLAYBOOK.md` for this new app. Mirror the BKE implementation. Use the BKE codebase at `<reference-app>/src/` as the reference — when in doubt about a pattern, read the BKE file rather than re-deriving.
>
> Inputs:
> - SUPABASE_URL + ANON_KEY + SERVICE_ROLE_KEY: [provided in .env]
> - OAuth providers: [list, or "email/password only"]
> - Email confirmation: [enabled / disabled]
> - Onboarding steps: [list — typically welcome → workspace → config → plan]
>
> Pause after `proxy.ts` + auto-profile trigger ship for review.

---

**Reference files in BKE:**
- `src/proxy.ts` — edge auth gateway
- `src/lib/supabase.ts` — browser client
- `src/lib/supabase-server.ts` — server + admin clients
- `src/lib/user-auth.ts` — requireUser() helper
- `src/lib/admin.ts` — account status helpers
- `src/app/onboarding/` — wizard routes
- `src/app/dashboard/layout.tsx` — hydration + gating
- `supabase/migrations/00068_enroll_trial_signup_on_profile_create.sql` — auto-enroll trigger
