Files
bsk/docs/runbooks/first-admin-setup.md
T
tiennm99 eb1af9013e feat(phase-1): admin enrollment (first-admin claim + invite flow)
- supabase/migrations/20260525163400_bsk_admin.sql:
  bsk.claim_first_admin(uuid) -> boolean, VOLATILE SECURITY DEFINER.
  Advisory lock keyed by hashtext('bsk:claim_first_admin')::bigint
  serializes concurrent first-sign-ins; EXISTS-guarded INSERT means
  only the first caller wins.
- types/supabase-bsk.ts: added claim_first_admin to bsk.Functions.
- lib/auth/invite-schema.ts: InviteUserSchema (Zod v4: email + role
  enum derived from appRoles) + InviteUserState discriminated union.
- app/[locale]/(app)/admin/invite/{actions,page,form}.tsx: admin-only
  invite Server Action + page + RHF/useActionState client form.
  Caller-role check via getServerSession() (defense in depth; the
  (app)/admin layout in phase 06 will gate at the route level).
  Insert uses createSupabaseAdminClient() because app_users has no
  INSERT RLS policy by design.
- app/[locale]/(auth)/sign-in/actions.ts: extended enrollment-check
  branch — when no row AND count == 0, calls claim_first_admin RPC.
  On true, re-fetches enrollment row and proceeds; on false (race
  lost) or count > 0, falls through to existing sign-out + generic
  error (enumeration defense preserved).
- messages/{vi,en}.json: admin.invite.* keys (parity).
- docs/runbooks/first-admin-setup.md: happy path + manual psql
  fallback bootstrap procedure.

No audit_log refs — trimmed plan respected.
2026-05-25 17:47:47 +07:00

1.7 KiB

First-Admin Setup Runbook

Happy Path (automatic)

The first person to successfully sign in when bsk.app_users is empty is automatically granted the admin role.

Flow (implemented in signInAction + migration 20260525163400_bsk_admin.sql):

  1. User signs in with valid credentials.
  2. signInAction checks for an enrollment row in bsk.app_users.
  3. If no row exists, it reads COUNT(*) from bsk.app_users.
  4. If count is 0, it calls bsk.claim_first_admin(user_id).
  5. The SQL function acquires pg_advisory_xact_lock(hashtext('bsk:claim_first_admin')::bigint), then inserts (user_id, 'admin') only if the table is still empty.
  6. Returns true → sign-in proceeds as admin. Returns false (race lost) → generic error.

Two simultaneous first sign-ins: exactly one succeeds; the other receives "Invalid email or password" and can retry (their next sign-in will find count > 0 and be rejected as unenrolled until an admin invites them).

Manual Fallback (psql)

Use this if the automatic claim ever fails or you need to bootstrap a specific user directly.

-- 1. Find the user's UUID in auth.users
SELECT id, email FROM auth.users WHERE email = 'your@email.com';

-- 2. Insert the admin enrollment row
INSERT INTO bsk.app_users (user_id, role)
VALUES ('<uuid-from-step-1>', 'admin');

Run via Supabase dashboard SQL editor or psql with the connection string from your Supabase project settings.

Note: deleting all rows from bsk.app_users effectively resets the bootstrap — the next sign-in will claim admin again.

Reference

Migration: supabase/migrations/20260525163400_bsk_admin.sql Sign-in action: app/[locale]/(auth)/sign-in/actions.ts