Authentication & roles
Accounts
Users live in auth.users (Supabase-managed). Every auth user has a matching row in public.profiles created automatically by the handle_new_user() trigger — see migration 20260416111642_initial_schema.sql. Do not insert into profiles manually during signup.
The email of record lives in auth.users, but profiles.email mirrors it (added in 20260616000000_profiles_email.sql) so the admin Users page can show it through the normal profiles select instead of a service-role lookup. The copy is read-only — handle_new_user seeds it at signup, a before update trigger (profiles_email_mirror_auth) coerces it back to the auth.users value on any profile edit so it can't be spoofed, and an after update of email on auth.users trigger (sync_profile_email) propagates a future email change. It inherits the existing profiles SELECT policies, so only self, staff/admin, and a parent-of-child can read it. The app never writes it — hence it's absent from the Insert/Update types in src/types/database.ts.
Sign-up flow
There are two entry points, and neither one has an approval step.
/signup — direct account creation
A passport name, an email address and a password are the whole of the ask, plus a Cloudflare Turnstile challenge the diver never usually has to touch.
SignupPageposts to thecreate-accountedge function.- The function verifies the Turnstile token server-side, spends a per-IP budget (
record_signup_attempt, shared with the/registerfunnel), then callsauth.admin.createUser({ email_confirm: true })and signs the diver in. - The
handle_new_usertrigger writesprofiles(id = new.id)withrole = 'diver',status = 'active', thenamefromraw_user_meta_data, and a server-stampedagreed_to_terms_at. - The SPA installs the returned session and the diver lands on
/calendar.
Why an edge function rather than supabase.auth.signUp():
- Captcha. Supabase's own captcha setting arms every auth endpoint, sign-in and password-reset included. Verifying the token here puts the challenge on signup alone.
- No confirmation email.
email_confirm: truemarks the address confirmed at creation, so there is no click-the-link step between signing up and diving — regardless of the project's "Confirm email" dashboard toggle. - Rate limiting.
record_signup_attemptis a service-role RPC.
No profile field beyond the name is required, at signup or afterwards. ProfileForm on /profile saves whatever it is given; what is still blank is computed by src/lib/profile-completeness.ts and shown to staff on the admin screens — a gap is a prompt, never a block. Details the shop genuinely cannot dive without are collected at booking time by RegisterForm.
/register and /register/:id — one-shot signup + booking
Public funnel for visitors arriving from the shop's own site or a calendar deep-link. RegisterPage renders RegisterForm. On submit the form invokes the create-registration edge function, which:
- Guest path (caller has no Bearer JWT): the same Turnstile + per-IP gates, then
auth.admin.createUser({ email_confirm: true })— bypasses the click-to-confirm gate so a typo'd email is rejected loudly instead of silently dropping the account; immediately signs in so the SPA holds the session without a second round-trip. - Authed path (Bearer JWT): identifies the user via
auth.getUser().
In both cases the function then updates profiles, inserts the bookings row, and emails a registration PDF to the diver and the company inbox via Gmail SMTP. See supabase/functions/create-registration/index.ts.
What status is for now
profiles.status (pending | active | rejected) is a suspension lever, not a queue. Signing up no longer produces a pending row, so in normal operation nothing sits in that state. An admin can still move a live profile to pending or rejected from /admin/applications ("Suspended accounts"), and doing so still severs diver-side bookings and push_subscriptions inserts through the is_active_user() RLS helper and bounces the diver to /pending.
Before migration 20260831120000 every new account started at pending and waited for a human. Divers read the wait as the site being broken, and a shop that approves essentially everyone got no safety from the queue.
Closing an account is not deleting it
Nothing in the app deletes a diver. "Close account" writes status = 'rejected' and stops there: the auth.users row, the email address, the profile and every booking survive. admin_delete_user does delete (delete from auth.users), but it is wired to the diver directory's Delete user button, not to this flow.
That distinction used to be invisible. /admin/applications listed status = 'pending' alone, so closing an account made it disappear from the one screen that can reopen it, and admins reasonably read the disappearance as a delete. When the diver came back they reached for Create diver, which calls auth.admin.createUser and fails with email_exists — the address is still held by the account they think they deleted.
Both halves are fixed: the page lists closed accounts in a second section with a Reinstate button, and admin-create-diver answers email_exists with a 409 naming the existing account's state instead of a bare "createUser failed" (safeError suppresses the message of any error carrying a code, and AuthApiError carries one).
Reinstating runs through the same notify-application-decision call as approving an on-hold account — decision: 'approve' — so the diver gets the "your account is active again" email either way. Nothing else changes: they keep their own password, their history and their terms consent.
Terms re-acceptance
RequireCurrentTerms used to redirect every authenticated route to /terms?reaccept=1 the moment profiles.agreed_to_terms_version fell behind the live terms.version. It now renders a banner above the page instead, linking to the same flow. The banner is not dismissible — it stays until the diver accepts, so the shop still gets its consent — but nothing behind it is blocked meanwhile. Signup is unaffected: /signup takes consent to the current version before the account exists.
Sign-in flow
LoginPagecallssignInWithPassword.- After success, it reads
profiles.roleonce and redirects:admin→/adminstaff→/admin/eventsdiver→/calendar
Local dev quick-fill buttons cover all three: diver@diver.diver / admin@admin.admin / staff@staff.staff.
useAuth hook
src/hooks/useAuth.ts exposes { session, user, profile, loading, signOut }:
- Subscribes to
supabase.auth.onAuthStateChange. - Re-fetches the full profile row whenever the session changes.
loadingstaystrueuntil the first session + profile resolve — UI shells should render a spinner until!loading.
Role gating
Three roles: diver, staff, admin. The check constraint in profiles_role_check enforces this DB-side. Promotion is not exposed in the UI — flip profiles.role in Supabase Studio / SQL editor with the service role.
| Role | Sees | Can mutate |
|---|---|---|
diver | own data | own bookings/profile |
staff | every diver's profile/bookings/payments + own duties | nothing on the catalog or other users |
admin | everything | everything |
Route guards (each gates one slice of the tree):
ProtectedRoute(src/components/layout/ProtectedRoute.tsx) — gates on!!session. Unauthenticated users bounce to/login.StaffOrAdminRoute(src/components/layout/StaffOrAdminRoute.tsx) — read-only admin surfaces (events list, event detail, gear map). Allowsstaff+admin.AdminRoute(src/components/layout/AdminRoute.tsx) — write surfaces (catalog editors, user directory, duty assignment, notifications, new/edit event). Admin-only; staff bounces.
Route structure in src/App.tsx (representative — see file for the authoritative list):
/login /signup /forgot-password /reset-password /terms
/register /register/:id (public, no auth)
<ProtectedRoute>
<AppShell> /dashboard /calendar /map /bookings /payments
/profile /duties
/minigame/eel-snake
<StaffOrAdminRoute>
<AdminShell> /admin /admin/events
/admin/events/:id
/admin/events/:id/gear-map
<AdminRoute>
<AdminShell> /admin/new /admin/new/event /admin/rooms
/admin/addons /admin/travel /admin/prices
/admin/events/:id/edit /admin/users
/admin/duty /admin/notificationsAppShell renders the diver UI with a bottom nav; AdminShell is the admin mirror. Admins see both: from AppShell there's a "view as admin" link, and AdminShell has "view as diver" — see admin.md.
RLS interaction
Auth role drives RLS. A diver only sees their own bookings / payments / push_subscriptions; staff and admin share broader select policies on profiles, bookings, payments, admin_notes (gated through the is_staff_or_admin() SQL helper). Writes on those tables stay admin-only via is_admin(). The push worker uses the service role key and bypasses RLS entirely — that's why it lives server-side, never in the browser.
See data-model.md § Row-Level Security for the policy patterns and the is_admin() / is_staff_or_admin() helpers.
Test accounts (local dev)
LoginPage exposes quick-fill buttons in dev mode. Credentials live at the top of src/pages/LoginPage.tsx — read them from there rather than relying on this doc, since they drift. Create them via the Supabase dashboard or CLI (supabase auth admin create-user) before first run; see the header comment in 20260416111642_initial_schema.sql.
Integration tests create throwaway users on the fly via createTestUser() in tests/integration/helpers.ts — use those in new integration tests rather than the dev accounts.