From 0a08f8045072b953975d721f001098fac90fa8b3 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Mon, 25 May 2026 17:23:08 +0700 Subject: [PATCH] feat(phase-1): db schema init (bsk_init migration + roles) - supabase/migrations/20260525163300_bsk_init.sql: bsk schema, app_role enum, app_users enrollment table, current_role() SECURITY DEFINER STABLE helper, RLS enabled with two SELECT policies (own row + admin) - types/supabase-bsk.ts: hand-written placeholder mirroring canonical supabase gen output; regenerate via pnpm db:gen-types after migration applied - lib/db/roles.ts: appRoles tuple + AppRole union + satisfies guard against generated enum + isAppRole type guard - package.json: db:gen-types script - README: one-line note on regenerating types after db:push - plans/: include phase 02/04/06 detail files alongside the existing 01/03/05 (planner output that wasn't committed with the trim) --- README.md | 4 + lib/db/roles.ts | 31 +++ package.json | 1 + .../phase-02-auth-session-wiring.md | 172 +++++++++++++++ .../phase-04-sign-in-page.md | 177 ++++++++++++++++ .../phase-06-role-gated-shell.md | 199 ++++++++++++++++++ scripts/check-no-secret-leak.mjs | 18 +- .../migrations/20260525163300_bsk_init.sql | 118 +++++++++++ types/supabase-bsk.ts | 65 ++++++ 9 files changed, 782 insertions(+), 3 deletions(-) create mode 100644 lib/db/roles.ts create mode 100644 plans/260525-1633-phase-01-identity-access/phase-02-auth-session-wiring.md create mode 100644 plans/260525-1633-phase-01-identity-access/phase-04-sign-in-page.md create mode 100644 plans/260525-1633-phase-01-identity-access/phase-06-role-gated-shell.md create mode 100644 supabase/migrations/20260525163300_bsk_init.sql create mode 100644 types/supabase-bsk.ts diff --git a/README.md b/README.md index 58fe675..33a6e68 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ Planning phase. See [PLAN.md](./PLAN.md) for the architecture and phased roadmap - **No HIPAA / GDPR compliance** is implied or attempted on the free-tier infrastructure. - This is a derivative work for learning; if you are the upstream author and would like additional attribution or removal, please open an issue. +## Database + +After `pnpm db:push`, run `pnpm db:gen-types` to refresh `types/supabase-bsk.ts`. + ## License This repository is licensed under the [Apache License 2.0](./LICENSE). The original project does not currently carry an explicit license; see [NOTICE](./NOTICE) for the attribution stance. diff --git a/lib/db/roles.ts b/lib/db/roles.ts new file mode 100644 index 0000000..87898b4 --- /dev/null +++ b/lib/db/roles.ts @@ -0,0 +1,31 @@ +/** + * BSK role constants and type utilities. + * + * `appRoles` is the single source of truth for the ordered list of role values. + * `AppRole` is derived from it so the union never drifts from the tuple. + * The `satisfies` guard at the bottom catches any drift between this file and + * the generated database types at typecheck time — no runtime cost. + */ + +import type { Database } from "@/types/supabase-bsk"; + +// Ordered tuple — used for iteration (dropdowns, role-badge maps, etc.) +export const appRoles = ["admin", "doctor", "nurse", "receptionist", "cashier", "patient"] as const; + +/** Union of all valid BSK role strings, derived from the tuple above. */ +export type AppRole = (typeof appRoles)[number]; + +/** + * Compile-time guard: if the database enum and this tuple ever diverge, + * typecheck fails here — not at a runtime crash in production. + */ +const _roleGuard: AppRole[] = [] satisfies Database["bsk"]["Enums"]["app_role"][]; +void _roleGuard; // prevent unused-variable lint warning + +/** + * Returns true if `s` is a valid `AppRole` value. + * Use as a type-narrowing guard when validating external input. + */ +export function isAppRole(s: string): s is AppRole { + return (appRoles as readonly string[]).includes(s); +} diff --git a/package.json b/package.json index 21f7a23..e183745 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "format:check": "prettier --check .", "db:preflight": "tsx scripts/preflight-supabase.ts", "db:push": "pnpm db:preflight && supabase db push", + "db:gen-types": "supabase gen types typescript --schema bsk > types/supabase-bsk.ts", "check:no-secret-leak": "node scripts/check-no-secret-leak.mjs" }, "dependencies": { diff --git a/plans/260525-1633-phase-01-identity-access/phase-02-auth-session-wiring.md b/plans/260525-1633-phase-01-identity-access/phase-02-auth-session-wiring.md new file mode 100644 index 0000000..c9dfce0 --- /dev/null +++ b/plans/260525-1633-phase-01-identity-access/phase-02-auth-session-wiring.md @@ -0,0 +1,172 @@ +# Phase 02 — Auth Session Wiring + +## Context Links +- `PLAN.md` §3.1 (Next.js 16 / `proxy.ts` / `'use cache'`) +- `CONTRIBUTING.md` §2 (`'use cache'` constraints), §3 (Supabase + cache interaction) +- Scout citations: + - `proxy.ts:1-8` — current next-intl-only middleware + - `lib/supabase/session.ts:11-14` — JSDoc explicitly tells future-self how to wire this phase (refresh BEFORE next-intl; merge cookies into a single `NextResponse`) + - `lib/supabase/server.ts:10-34` — per-request server client; correctly outside any cached scope + - `app/[locale]/layout.tsx:25-47` — current layout reads i18n only; this phase adds session read +- Researcher refs: + - Supabase docs say proxy must `request.cookies.set` + `response.cookies.set` for refreshed tokens + - next-intl docs: compose by calling `createMiddleware()` inside a custom `proxy(request)` and modifying the returned response +- Brainstormer F1 in `plans/reports/brainstormer-architecture-redteam.md` — flagged this as the "thing that WILL break" if mishandled + +## Overview +- **Priority:** P1 (gates every authenticated route) +- **Status:** pending +- **Brief:** Compose `next-intl/middleware` and `@supabase/ssr` session refresh into a single `proxy.ts`. Read the session in the root `[locale]/layout.tsx` (outside any `'use cache'` scope). Establish the pattern: cookies read at the request entry → user object passed as argument into cached helpers. + +## Key Insights +- next-intl's `createMiddleware` returns a `NextResponse`. Supabase's session-refresh helper ALSO needs to read+write cookies on a `NextResponse`. Two separate responses = lost cookies. **Single composed response.** +- Order matters. The recommended order from Supabase docs + community is: (1) build `response = NextResponse.next({ request })`, (2) run Supabase refresh on it (reads incoming cookies, writes refreshed cookies onto BOTH `request.cookies` and `response.cookies`), (3) hand off to next-intl which may produce its own `NextResponse` for redirects/rewrites — at which point we must **port Supabase's cookies onto the next-intl response** before returning. +- `'use cache'` constraint: `createSupabaseServerClient()` reads `cookies()`. It MUST be called at page/layout/Server-Action top-level. The layout reads the user once and passes it down. Cached helpers (none yet in this phase) receive `user` as a function argument, never re-read cookies. +- `lib/supabase/session.ts:39` swallows `auth.getUser()` rejection — that's intentional (transient Supabase outage). Confirmed acceptable per code-reviewer report N6. +- `getUser()` not `getSession()`. `getSession()` returns the JWT without server-side validation; `getUser()` round-trips to Supabase and validates. For server-side auth gating use `getUser()`. (Per Supabase docs.) +- Public routes (`/`, `/sign-in`) must NOT redirect to sign-in. Protected routes (`/dashboard`, `/admin/**`, future feature routes) MUST. The proxy is **not** the authorization layer — it only refreshes the session. Authorization is a layout-level concern (phase 06). The proxy only does the cookie work + a coarse redirect for unauth users hitting protected paths (cheap; defense-in-depth). + +## Requirements + +### Functional +- F-02-1: `proxy.ts` exports a default async function `proxy(request: NextRequest)`. The matcher unchanged from current (`PLAN: /((?!api|_next|_vercel|.*\\..*).*)`). +- F-02-2: The proxy calls `updateSupabaseSession(request)` (from `lib/supabase/session.ts`) FIRST. This refreshes cookies and returns a `NextResponse` carrying the refreshed `Set-Cookie` headers. +- F-02-3: The proxy then calls next-intl's `handleI18nRouting(request)`. If next-intl returns a redirect/rewrite response, the proxy MUST copy Supabase's refreshed cookies from the first response onto the next-intl response before returning. If next-intl returns a plain `NextResponse.next()`-equivalent, the proxy returns the Supabase response (cookies already attached). +- F-02-4: `lib/supabase/session.ts` exports `updateSupabaseSession(request)` returning `{ response: NextResponse, user: User | null }`. Phase 02 may extend the existing signature (currently returns just `response`); update callers accordingly. +- F-02-5: `lib/supabase/session.ts` exports a constant `PROTECTED_PATH_PREFIXES: ReadonlyArray` = `['/dashboard', '/admin']` (extend in later phases) — a coarse list for the proxy-level "no user → redirect to /sign-in" check. The matcher already excludes `/api`, `_next`, etc. +- F-02-6: When `user === null` AND the path matches a protected prefix (after stripping locale), proxy returns a redirect to `/${locale}/sign-in?next=${encodeURIComponent(originalPath)}`. Locale is detected from path or defaulted to `routing.defaultLocale`. +- F-02-7: `app/[locale]/layout.tsx` reads `const supabase = await createSupabaseServerClient(); const { data: { user } } = await supabase.auth.getUser();` (outside any cached scope), then passes `user` into a `` client wrapper (thin context for client components that need it; phase 06 consumes). +- F-02-8: Add a comment to `app/[locale]/layout.tsx` warning that the `getUser()` call MUST stay outside any `'use cache'` scope and that this layout file MUST NOT have `'use cache'` at the top. + +### Non-functional +- N-02-1: Proxy total file under 80 LOC. If composition logic grows, extract `lib/proxy/compose.ts`. +- N-02-2: `updateSupabaseSession` remains the only place that creates a `@supabase/ssr` client from `request`-cookies (ESLint override at `eslint.config.mjs:48-55` already lists `lib/supabase/session.ts` as a permitted importer). +- N-02-3: No new env vars. Reuses `lib/env/server.ts` outputs. +- N-02-4: Performance: proxy must NOT block on more than one Supabase RTT per request. `updateSupabaseSession` already calls `getUser()` exactly once. + +## Architecture + +### Request flow +``` +NextRequest + │ + ▼ +proxy.ts (single entry; no '_next', no '/api', no dotted paths) + │ + ├─ 1. response = NextResponse.next({ request }) + │ + ├─ 2. updateSupabaseSession(request) + │ ├─ creates @supabase/ssr server client with cookie adapter + │ ├─ writes refreshed Supabase cookies onto BOTH request.cookies + response.cookies + │ └─ returns { response, user } + │ + ├─ 3. coarse auth gate: + │ if !user && pathname matches PROTECTED_PATH_PREFIXES + │ return NextResponse.redirect('/${locale}/sign-in?next=...') + │ ← but FIRST copy supabase cookies onto the redirect response + │ + └─ 4. intlResponse = handleI18nRouting(request) + if intlResponse is a redirect/rewrite (intlResponse.status !== 200 || has rewrite header) + copy supabase cookies → intlResponse + return intlResponse + else + return response (the supabase one — already has cookies) +``` + +### Cookie-merge helper (pseudocode) +``` +function copyCookies(from: NextResponse, to: NextResponse) { + for (const cookie of from.cookies.getAll()) + to.cookies.set(cookie.name, cookie.value, cookie /* options */) +} +``` + +### Layout read flow +``` +[locale]/layout.tsx (async, no 'use cache') + ├─ await params → locale + ├─ setRequestLocale(locale) + ├─ supabase = await createSupabaseServerClient() + ├─ { user } = await supabase.auth.getUser() + └─ render + + {children} + + +``` + +## Related Code Files + +### Files to modify +- `proxy.ts` — compose Supabase + next-intl per F-02-1..F-02-6. File grows from ~8 LOC to ~50 LOC. +- `lib/supabase/session.ts` — change `updateSupabaseSession` to return `{ response, user }`. Currently returns `NextResponse` (cited at `lib/supabase/session.ts:15-44`). Callers updated in proxy. +- `app/[locale]/layout.tsx` — add session read between `setRequestLocale(locale)` and the JSX return; wrap children in ``. + +### Files to create +- `lib/proxy/copy-cookies.ts` (~15 LOC) — `copyCookies(from, to)` helper used by `proxy.ts`. Extracted so phase 06 redirect helpers can reuse. +- `lib/auth/session-provider.tsx` (~25 LOC) — thin client component: `'use client'`, `createContext`, `useSession()` hook, default `null`. Read in client components in phase 06 (sidebar, sign-out button). +- `lib/auth/get-server-session.ts` (~20 LOC) — server-only helper `async function getServerSession(): Promise<{ user: User | null; role: AppRole | null }>` — called from server components / actions that need both user + role. Combines `createSupabaseServerClient().auth.getUser()` + `bsk.current_role()` RPC. NOT cached (depends on cookies). Used by phase 06 layout. + +### Files to delete +- None. + +## Implementation Steps +1. Refactor `lib/supabase/session.ts:15-44`: change return type to `{ response, user }`. Capture `data.user` from the `getUser()` call. Keep the swallow-on-error pattern but ensure `user` defaults to `null` on rejection. +2. Author `lib/proxy/copy-cookies.ts`. Pure function, no imports beyond `next/server` types. +3. Rewrite `proxy.ts`: + - Import `updateSupabaseSession`, `PROTECTED_PATH_PREFIXES` from `lib/supabase/session`, `createMiddleware` from `next-intl/middleware`, `routing` from `i18n/routing`, `copyCookies` from `lib/proxy/copy-cookies`. + - Build `handleI18nRouting = createMiddleware(routing)` outside the function (module-level) so it isn't rebuilt per request. + - Inside `proxy(request)`: run Supabase refresh → optional protected-path redirect (with cookie copy) → handoff to next-intl → final cookie copy if next-intl produced its own response. + - Keep matcher identical to current. +4. Author `lib/auth/session-provider.tsx`. Pure context wrapper. No useEffect, no Supabase calls — just receive the `user` from the server layout. +5. Author `lib/auth/get-server-session.ts`. Uses `createSupabaseServerClient` + RPC call to `bsk.current_role()`. Returns null role if not enrolled. +6. Modify `app/[locale]/layout.tsx`: add `await createSupabaseServerClient()` → `.auth.getUser()` block. Wrap children in ``. Add comment "do NOT add `'use cache'` to this layout — reads cookies()". +7. Confirm phase-01 migration applied (else `bsk.current_role()` RPC in step 5 throws — acceptable, the helper returns `{ user: null, role: null }` in that case). +8. Smoke-test locally: + - `pnpm dev` → load `/` → no auth required, no redirect. + - Load `/vi/dashboard` while unauth → redirect to `/vi/sign-in?next=%2Fvi%2Fdashboard`. + - Browser DevTools → Network → confirm `sb-*` cookies present on the redirect response. +9. `pnpm typecheck` + `pnpm lint` + `pnpm build` (the build is where `'use cache'` misuse fails — confirm it doesn't trip). + +## Todo List +- [ ] `lib/supabase/session.ts` returns `{ response, user }` +- [ ] `PROTECTED_PATH_PREFIXES` exported from `lib/supabase/session.ts` +- [ ] `lib/proxy/copy-cookies.ts` authored +- [ ] `proxy.ts` composes Supabase + next-intl with cookie merge +- [ ] `lib/auth/session-provider.tsx` authored +- [ ] `lib/auth/get-server-session.ts` authored +- [ ] `app/[locale]/layout.tsx` reads user; wraps children in SessionProvider; comment forbids `'use cache'` +- [ ] `pnpm typecheck` + `pnpm lint` + `pnpm build` green +- [ ] Manual smoke: unauth → `/dashboard` redirects with cookies intact + +## Success Criteria +- Unauth request to a protected path → 307 redirect to `/${locale}/sign-in?next=...`, with `Set-Cookie` headers for refreshed (or unchanged) Supabase cookies attached. +- Authed request to `/` → 200, no redirect, locale chosen correctly by next-intl. +- Authed request to `/dashboard` → 200 (after phase 06 lands the route; in this phase, 404 is acceptable since the route doesn't exist yet — the redirect MUST NOT fire). +- `pnpm build` succeeds — proves no `'use cache'` scope shelters a `cookies()` call. +- `app/[locale]/layout.tsx` has the warning comment and does NOT carry a `'use cache'` directive. + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| Two `NextResponse` instances → one cookie set lost → user appears signed out on next request | high (if naive) | high | Single composed response via `copyCookies`; F-02-3 explicit | +| `getUser()` round-trips Supabase on every request → latency | med | med | Acceptable; `@supabase/ssr` deduplicates within the same request. Phase 0 review confirmed approach. | +| Supabase Auth outage → all requests fail | low | high | `lib/supabase/session.ts:38-40` swallows `getUser()` error and treats user as null. Acceptable degradation — phase 06 then redirects to sign-in. | +| `'use cache'` accidentally added to layout → build fails | low (visible) | low | Comment in layout; CI runs `next build`; ESLint plugin `@next/eslint-plugin-next` flags some cases | +| next-intl middleware updates change return-shape | low | med | Pin `next-intl` in `package.json` (already at `^4.12.0`); add a comment in `proxy.ts` referencing the composition pattern | +| Coarse-redirect uses wrong locale (path stripping bug) | med | med | `i18n/routing.ts` exports the locales tuple; use it; default to `routing.defaultLocale` when no locale segment matches | +| Edge runtime regression on Supabase | low | high | Brainstormer flagged: proxy is Node.js runtime in Next.js 16+. Confirm in `proxy.ts` (no `export const runtime = 'edge'`) | +| Public `/sign-in` page accidentally triggers a redirect loop | low | high | Protected-prefix check skips `/sign-in`; explicit test in phase 07 E2E | + +## Security Considerations +- `getUser()` server-validates the JWT against Supabase Auth on every request. Stronger than `getSession()` which trusts the cookie blob. +- The proxy is **not** the authorization layer (Supabase guidance + brainstormer F8). It refreshes cookies and applies a coarse redirect. True authorization happens at the layout (`(app)/layout.tsx` in phase 06) and via RLS at the DB layer. +- Cookie merge order matters: Supabase MUST set its cookies on the response that is ultimately returned. If next-intl produces a redirect, Supabase cookies port onto it via `copyCookies`. Otherwise the user appears signed-out after the first locale-redirect. +- No secrets logged. The `setAll` swallow in `server.ts:27` and the `getUser()` swallow in `session.ts:39` do not log; F-02 keeps that behavior. Code-reviewer report N5/N6 noted optional dev-only logging — left out of scope for this phase. +- The redirect target uses `next=` query param (raw path-only, validated server-side in phase 03 to reject external URLs). + +## Next Steps +- Phase 03 consumes `lib/auth/get-server-session.ts` indirectly (signing in establishes the cookie that this phase reads). +- Phase 04 sign-in page calls `signInAction`; the redirect post-sign-in lands on `/${locale}/dashboard`, which this phase makes the proxy aware of. +- Phase 06 uses `useSession()` from `lib/auth/session-provider.tsx` for client-side sidebar gating; uses `getServerSession()` for server-component gating. diff --git a/plans/260525-1633-phase-01-identity-access/phase-04-sign-in-page.md b/plans/260525-1633-phase-01-identity-access/phase-04-sign-in-page.md new file mode 100644 index 0000000..ab09415 --- /dev/null +++ b/plans/260525-1633-phase-01-identity-access/phase-04-sign-in-page.md @@ -0,0 +1,177 @@ +# Phase 04 — Sign-In Page + +## Context Links +- `PLAN.md` §4 Phase 1 (sign-in form via RHF + Zod v4 + `useActionState`) +- `CONTRIBUTING.md` §7 (form recipe — NO `next-safe-action`/`zsa`) +- Scout citations: + - `app/[locale]/layout.tsx:25-47` — locale layout (extended in phase 02 with `SessionProvider`) + - `i18n/navigation.ts:4` — `Link`, `redirect`, `useRouter` from next-intl + - `messages/{vi,en}.json` — i18n catalog (phase 03 adds `auth.signIn.*` keys) + - phase 03 outputs: `SignInSchema`, `SignInState`, `signInAction` +- shadcn/ui components: `Input`, `Label`, `Button`, `Form` primitives (CLI v4) + `Sonner` for toasts + +## Overview +- **Priority:** P1 +- **Status:** pending +- **Brief:** Public sign-in page at `/[locale]/sign-in`. Client component using `react-hook-form` for UX (inline validation, controlled inputs, disabled-submit-while-pending) and `useActionState` to call `signInAction` via the native `
` prop. Renders shadcn primitives + i18n strings + Sonner toasts. Belongs to the `(auth)` route group so it has a distinct layout (centered, no sidebar). + +## Key Insights +- `(auth)` route group: `app/[locale]/(auth)/layout.tsx` is a minimal layout (no sidebar — just brand + content). Bypasses the phase 06 `(app)` layout that requires auth. +- RHF + `useActionState` integration shape (from researcher refs + Next.js community pattern): + 1. RHF owns form state (`useForm({ resolver: zodResolver(SignInSchema) })`). + 2. `useActionState(signInAction, initialState)` returns `[state, dispatchAction, isPending]`. + 3. Form `action` prop bound to `dispatchAction`. RHF's `handleSubmit` NOT used here — the native form action dispatches the FormData directly. RHF's role is to surface inline errors via `formState.errors` BEFORE submit (client validation), but submission goes through the React 19 form action mechanism. + 4. Server-returned field errors (from `state.fieldErrors`) merge into RHF errors via `useEffect(() => state.fieldErrors && form.setError(...))`. UX: client errors first, server errors as a fallback. +- Native `` automatically wraps submission in a Transition; `isPending` flag is the source of truth for the loading state. +- Sonner used only for transient toasts (rate-limit message, "signed out" confirmation on the post-redirect landing page in phase 06). Inline form errors via RHF + `state.formError` text. No toast for invalid creds — keeps the error near the form. +- The page accepts `?next=` query param (Promise<{ next?: string }> per Next.js 16 async searchParams), renders it as a hidden `` so the action sees it in `FormData`. + +## Requirements + +### Functional +- F-04-1: `app/[locale]/(auth)/layout.tsx` — centered card layout, no sidebar, app brand at top. Does not call `getServerSession()` (public route). Async; awaits `params`. +- F-04-2: `app/[locale]/(auth)/sign-in/page.tsx` — server component. Awaits `params` + `searchParams`. Validates `next` shape client-side too (defense in depth). Renders `` (client component). +- F-04-3: `app/[locale]/(auth)/sign-in/sign-in-form.tsx` — `'use client'`. The form component. Uses: + - `useForm` from `react-hook-form` with `zodResolver(SignInSchema)`. + - `useActionState(signInAction, { status: 'idle' })`. + - shadcn `Input`, `Label`, `Button`. + - `useTranslations('auth.signIn')` from `next-intl`. + - Renders email + password + hidden `next` + submit button. +- F-04-4: Form submit binds ``. NO `onSubmit={form.handleSubmit(...)}`. +- F-04-5: When `state.status === 'error'` and `state.formError` is set, render the form-level error above the submit button (red, role="alert"). +- F-04-6: When `state.status === 'rate_limited'`, render the rate-limit message with `retryAfterSeconds` interpolated. +- F-04-7: When server `fieldErrors` arrive, sync them into RHF via `useEffect` so the inline error UI is consistent. +- F-04-8: Submit button disabled when `isPending || !form.formState.isValid`. Button label switches to the `submitting` i18n string when `isPending`. +- F-04-9: Already-signed-in users hitting `/[locale]/sign-in` are redirected to `/[locale]/dashboard`. Implementation: `page.tsx` calls `getServerSession()`; if `user` not null → `redirect('/${locale}/dashboard')`. + +### Non-functional +- N-04-1: Page + form file together ≤ 200 LOC. Form file ≤ 150 LOC. +- N-04-2: No `next-safe-action` / `zsa` / `safe-action` imports. +- N-04-3: No Supabase client imports in client component (form uses Server Action only). +- N-04-4: shadcn components installed via CLI: `pnpm dlx shadcn@latest add input label button form sonner`. Files land under `components/ui/`. +- N-04-5: Sonner `` mounted once at the `(app)`/`(auth)` layout shared ancestor; for this phase, add to `app/[locale]/layout.tsx` (single global mount). + +## Architecture + +### Route tree (after this phase + phase 06) +``` +app/ +└─ [locale]/ + ├─ layout.tsx (i18n + session provider + Toaster) + ├─ page.tsx (existing home) + ├─ (auth)/ + │ ├─ layout.tsx (centered, no sidebar) + │ └─ sign-in/ + │ ├─ page.tsx (server, awaits params/searchParams) + │ ├─ sign-in-form.tsx ('use client') + │ └─ actions.ts (from phase 03; 'use server') + └─ (app)/ (phase 06) + ├─ layout.tsx (requires auth) + └─ dashboard/page.tsx (phase 06) +``` + +### Form data flow +``` +User types + │ + ▼ +RHF onChange → form.formState.errors (client-side Zod) + │ + │ submit + ▼ + + │ (React 19 wraps in Transition; isPending=true) + ▼ +signInAction(prevState, formData) (phase 03) + │ + ▼ +returns SignInState + │ + ▼ +useActionState re-renders + │ + ├─ state.status === 'error' → render formError, sync fieldErrors into RHF + ├─ state.status === 'rate_limited' → render rate-limit banner + └─ (success path never returns; action redirects) +``` + +## Related Code Files + +### Files to create +- `app/[locale]/(auth)/layout.tsx` (~30 LOC) +- `app/[locale]/(auth)/sign-in/page.tsx` (~40 LOC) +- `app/[locale]/(auth)/sign-in/sign-in-form.tsx` (~140 LOC client component) +- `components/ui/{input,label,button,form,sonner}.tsx` — generated by shadcn CLI; do not hand-edit. + +### Files to modify +- `app/[locale]/layout.tsx` — mount `` from `sonner` once globally (inside `NextIntlClientProvider`). +- `messages/vi.json` + `messages/en.json` — keys added in phase 03 (re-verify present). +- `components.json` — created by shadcn init in phase 0; verify settings (alias, RSC=true). + +### Files to delete +- None. + +## Implementation Steps +1. Install shadcn primitives: `pnpm dlx shadcn@latest add input label button form sonner`. Confirm files land in `components/ui/`. +2. Mount Sonner `` in `app/[locale]/layout.tsx` inside `NextIntlClientProvider` and outside `SessionProvider` (toast events are user-agnostic). +3. Author `app/[locale]/(auth)/layout.tsx`. Minimal: centered flex container, brand at top, `{children}`. Async function awaits `params`. +4. Author `app/[locale]/(auth)/sign-in/page.tsx`. Async server component: + - Awaits `params` (Locale) and `searchParams` ({ next?: string }). + - Calls `getServerSession()` from phase 02. If user exists → `redirect('/${locale}/dashboard')`. + - Renders ``. +5. Author `app/[locale]/(auth)/sign-in/sign-in-form.tsx`: + - `'use client'`. + - `useForm<{ email: string, password: string }>({ resolver: zodResolver(SignInSchema), mode: 'onBlur' })`. + - `useActionState(signInAction, { status: 'idle' })`. + - `useEffect` to mirror `state.fieldErrors` → `form.setError(...)`. + - `useEffect` to call `toast.error(t('rateLimited', { seconds }))` when status flips to `rate_limited` (so the message hangs around even on re-render). + - JSX: `` containing email + password + hidden `next` + submit button + form-level error region. +6. Verify the form action passes through correctly: ``, ``, ``. Names MUST match `SignInSchema` keys (FormData → object via the schema). +7. i18n: confirm `messages/{vi,en}.json` has `auth.signIn.title`, `subtitle`, `emailLabel`, `passwordLabel`, `submit`, `submitting`, `invalidCredentials`, `rateLimited`, `unenrolledError`, `genericError`. +8. Visual smoke: `pnpm dev` → `/vi/sign-in` and `/en/sign-in` render; locale switcher (`Link` from `i18n/navigation`) optional in this phase. +9. `pnpm typecheck` + `pnpm lint` + `pnpm build`. + +## Todo List +- [ ] shadcn primitives installed +- [ ] Sonner Toaster mounted globally +- [ ] `(auth)/layout.tsx` authored +- [ ] `(auth)/sign-in/page.tsx` authored — awaits async params + searchParams; redirects already-signed-in users +- [ ] `(auth)/sign-in/sign-in-form.tsx` authored — RHF + useActionState integration +- [ ] i18n keys verified (both locales) +- [ ] Visual smoke: form renders in both locales +- [ ] `pnpm build` green (catches `'use cache'` regressions, missing await) + +## Success Criteria +- `/vi/sign-in` and `/en/sign-in` both render the form with localized strings. +- Invalid email → inline RHF error (no server round-trip). +- Empty password → inline RHF error. +- Valid form → submit → if creds invalid, generic error renders; if creds valid + enrolled, redirect to `/${locale}/dashboard`. +- 6th rapid attempt → rate-limit banner with seconds remaining. +- Already-signed-in user hitting `/sign-in` → redirected to `/${locale}/dashboard`. +- Submit button disabled while pending; label shows the `submitting` i18n string. + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| RHF `handleSubmit` used by mistake → bypasses Server Action | med | high | Code review checklist; comment in form file: "form action is dispatchAction, NOT handleSubmit" | +| Form field name mismatch with Zod schema → silent validation pass with empty values | med | med | Zod schema keys + `` reviewed together; phase 07 E2E catches | +| `'use client'` accidentally added to page.tsx (breaks getServerSession redirect) | low | med | Convention: server components are the default; client components have explicit `'use client'` at top | +| `` mounted twice → duplicate toasts | low | low | Single mount in root locale layout | +| Submitting a logged-out form race with refreshing tab → action returns success then layout redirects | low | low | Acceptable; user sees the dashboard after redirect | +| Locale switcher omitted → users can't switch languages | med | low | Defer to phase 06 shell; phase 04 ships single-locale UX only | +| shadcn `Form` component (RHF-aware) couples to `Form.Field` API that differs from native — confusion | low | low | Use shadcn `Input`/`Label`/`Button` primitives + raw RHF; skip `Form.*` composition (YAGNI) | +| Server returns redirect, browser sees both old and new pages briefly | low | low | React 19's Transition smooths this; acceptable | + +## Security Considerations +- Password field: `type="password"`, `autoComplete="current-password"`. Email: `type="email"`, `autoComplete="email"`. +- Form submitted over HTTPS in prod (Vercel default). +- No password ever stored in client state beyond RHF's internal buffer (cleared on unmount). +- Error rendering keeps wording generic (delegated to phase 03 actions); no "wrong password" vs "no such user". +- `next` param echoed back into the form via hidden input — server validates per phase 03 F-03-4 step 6. Client display is read-only; no XSS surface (React escapes). +- No client-side Supabase calls. All auth state derived from server-rendered `user` via `SessionProvider` (phase 02). + +## Next Steps +- Phase 05 consumes `signOutAction` (sign-out button in header) and adds the admin-invite action + UI sketch. +- Phase 06 builds the `(app)/layout.tsx` and the dashboard placeholder that this form redirects into. +- Phase 07 E2E covers the form's happy path + invalid path + rate-limit path. diff --git a/plans/260525-1633-phase-01-identity-access/phase-06-role-gated-shell.md b/plans/260525-1633-phase-01-identity-access/phase-06-role-gated-shell.md new file mode 100644 index 0000000..5f42359 --- /dev/null +++ b/plans/260525-1633-phase-01-identity-access/phase-06-role-gated-shell.md @@ -0,0 +1,199 @@ +# Phase 06 — Role-Gated Layout Shell + +## Context Links +- `PLAN.md` §4 Phase 1 (role-gated layout shell; sidebar shows only what role can access) +- Scout citations: + - phase 02 outputs: `getServerSession()`, ``, `useSession()` + - phase 01 outputs: `bsk.current_role()`, `lib/db/roles.ts`'s `appRoles` + `AppRole` union + - phase 03 outputs: `signOutAction` + - `i18n/navigation.ts:4` — locale-aware `Link` +- shadcn primitives: install `dropdown-menu`, `avatar`, `separator`, `sheet` (mobile sidebar) + +## Overview +- **Priority:** P1 +- **Status:** pending +- **Brief:** The authenticated app shell. `(app)` route group with a layout that requires `getServerSession()` to return a non-null user AND role; otherwise redirects. Sidebar items mapped per role. Dashboard placeholder page. Sign-out button. Locale switcher. Admin-only routes (`/admin/**`) double-gated by a nested layout. + +## Key Insights +- Layout-level gating runs on every request to a `(app)` route. Cheap because `getServerSession()` re-reads cookies + makes one RPC call to `bsk.current_role()` — already done in proxy, so the layout reuses the same auth state. +- Sidebar mapping table lives in `lib/auth/role-menu.ts` — a single source of truth `{ [role]: MenuItem[] }`. Server-rendered (RSC), so no JS-side gating dance. +- `useSession()` from `` is client-side. Used only for the avatar / display name / sign-out button. Authorization decisions stay on the server. +- The proxy (phase 02) does a coarse "no user → redirect" for protected prefixes. The `(app)` layout does the FINE-grained "user but no enrollment / no role → sign-out + redirect" — defends against an authed-but-unenrolled user reaching protected pages somehow. +- Admin-only nested layout: `app/[locale]/(app)/admin/layout.tsx` calls `getServerSession()` and `if (role !== 'admin') redirect('/${locale}/dashboard')`. Two-level enforcement is intentional: (a) sidebar omits admin items for non-admins (cosmetic), (b) layout-level redirect (security). +- Dashboard placeholder: `/[locale]/(app)/dashboard/page.tsx` is a minimal "Welcome, {name}. Role: {role}" page. Real dashboard content lands in later phases. +- Locale switcher uses `useRouter` from `i18n/navigation`. Preserves the current path when switching `vi ↔ en`. + +## Requirements + +### Functional +- F-06-1: `app/[locale]/(app)/layout.tsx` — server component: + - Awaits `params` (Locale). + - Calls `getServerSession()`. If `user === null` → `redirect('/${locale}/sign-in')` (defense in depth, even though proxy already redirected). If `role === null` (user authed but not enrolled — should be impossible past phase 03 + 05, but possible if admin deleted their `app_users` row mid-session) → trigger `signOutAction` redirect + flash an error. + - Renders the shell: `{children}`. +- F-06-2: `components/app-shell/app-shell.tsx` — server component. Composes `` + `` + `
`. Receives `user`, `role` as props. +- F-06-3: `components/app-shell/sidebar.tsx` — server component. Reads `role` prop, looks up `ROLE_MENU[role]`, renders `` items using `i18n/navigation`. Empty for unknown role (impossible because layout pre-validates). +- F-06-4: `components/app-shell/top-bar.tsx` — `'use client'`. Right-side: locale switcher + avatar dropdown (display name + sign-out button — sign-out is a form with `action={signOutAction}`). +- F-06-5: `lib/auth/role-menu.ts` — `MenuItem` type + `ROLE_MENU: Record` table. Items: `{ href: string, labelKey: string, icon: ComponentType }`. Use `lucide-react` icons. +- F-06-6: Role-menu rough mapping (refined per phase as features land): + - `admin`: Dashboard, Patients, Doctors, Services, Medicines, Reports, Admin → Invite Users, Admin → Settings. + - `doctor`: Dashboard, Queue, Checkups, Patients (read-only). + - `nurse`: Dashboard, Queue, Checkups (assist). + - `receptionist`: Dashboard, Queue, Register Patient. + - `cashier`: Dashboard, Invoices, Payments. + - `patient`: Dashboard (self-portal placeholder). +- F-06-7: `app/[locale]/(app)/dashboard/page.tsx` — placeholder. Awaits `params`. Renders "Welcome, {name} ({role})". Uses i18n. +- F-06-8: `app/[locale]/(app)/admin/layout.tsx` — admin-gate layout. `getServerSession()` → if `role !== 'admin'` → `redirect('/${locale}/dashboard')` (could 404 instead; redirect feels nicer + avoids leaking that admin routes exist). +- F-06-9: Sign-out button form: ``. Triggers `signOutAction` from phase 03. +- F-06-10: Locale switcher: client-side, uses `useRouter()` + `usePathname()` from `i18n/navigation` to replace current locale. + +### Non-functional +- N-06-1: Sidebar file ≤ 80 LOC. TopBar ≤ 100 LOC. AppShell ≤ 60 LOC. +- N-06-2: No client-side Supabase calls. All session info comes from server-rendered props or `useSession()` context. +- N-06-3: Mobile responsive: shadcn `Sheet` for the mobile drawer. Desktop fixed sidebar. +- N-06-4: Sidebar items use `Link` from `i18n/navigation` (locale-aware). + +## Architecture + +### Layout tree +``` +app/ +└─ [locale]/ + ├─ layout.tsx (phase 02: session provider, i18n) + ├─ (auth)/ (phase 04: public) + │ ├─ layout.tsx + │ └─ sign-in/... + └─ (app)/ (phase 06: auth required) + ├─ layout.tsx (gate: user + role) + ├─ dashboard/page.tsx (placeholder) + └─ admin/ (admin-only) + ├─ layout.tsx (gate: role === 'admin') + ├─ invite/... (phase 05) + └─ settings/... (future) +``` + +### Auth gate flow +``` +Request → proxy.ts (phase 02) + │ if !user & protected-prefix → redirect /sign-in + ▼ +[locale]/layout.tsx + │ get user from createSupabaseServerClient + │ wrap in SessionProvider + ▼ +(app)/layout.tsx + │ getServerSession() → { user, role } + │ if !user → redirect /sign-in + │ if !role → signOut + redirect /sign-in with error + ▼ +AppShell renders + ├─ Sidebar (server, role-aware) + ├─ TopBar (client, useSession + locale switcher + signOut button) + └─ {children} + ▼ + (admin)/layout.tsx (only on admin routes) + │ if role !== 'admin' → redirect /dashboard + ▼ + admin pages +``` + +### Role-menu table (pseudocode, source of truth) +``` +ROLE_MENU: { + admin: [Dashboard, Patients, Doctors, Services, Medicines, Reports, Admin:Invite, Admin:Settings], + doctor: [Dashboard, Queue, Checkups, Patients], + nurse: [Dashboard, Queue, Checkups], + receptionist: [Dashboard, Queue, Register], + cashier: [Dashboard, Invoices, Payments], + patient: [Dashboard] +} +``` +Items reference `labelKey` like `'nav.dashboard'`, `'nav.queue'`, etc. — all in i18n catalogs. + +## Related Code Files + +### Files to create +- `app/[locale]/(app)/layout.tsx` (~40 LOC) +- `app/[locale]/(app)/dashboard/page.tsx` (~30 LOC) +- `app/[locale]/(app)/admin/layout.tsx` (~25 LOC) +- `components/app-shell/app-shell.tsx` (~50 LOC, server) +- `components/app-shell/sidebar.tsx` (~70 LOC, server) +- `components/app-shell/top-bar.tsx` (~90 LOC, client) +- `components/app-shell/locale-switcher.tsx` (~40 LOC, client) +- `components/app-shell/sign-out-button.tsx` (~25 LOC, client wraps `
` so the button can show pending state) +- `lib/auth/role-menu.ts` (~80 LOC) — `MenuItem` + `ROLE_MENU` table. +- `lib/auth/require-role.ts` (~20 LOC) — server helper `requireRole(allowed: AppRole[])` calls `getServerSession()`, redirects if no match. Used by admin layout + future per-route gates. + +### Files to modify +- `messages/{vi,en}.json` — add `nav.*` keys (dashboard, queue, patients, doctors, services, medicines, reports, register, invoices, payments, admin) + `app.signOut`, `app.localeSwitcher.*`, `app.unenrolledError`. +- `components/ui/{dropdown-menu,avatar,separator,sheet}.tsx` — shadcn CLI installs. + +### Files to delete +- None. + +## Implementation Steps +1. Install shadcn primitives: `pnpm dlx shadcn@latest add dropdown-menu avatar separator sheet`. +2. Author `lib/auth/role-menu.ts`. Hand-curate the mapping per F-06-6. Use `lucide-react` icons sparingly. +3. Author `lib/auth/require-role.ts`. Server-only helper. Re-exports for layout gates. +4. Author `components/app-shell/sidebar.tsx`. Server component. Renders the role's menu items. Active-link highlighting via `usePathname` is client-side — defer; sidebar stays server-rendered for this phase. +5. Author `components/app-shell/locale-switcher.tsx`. Client. Uses `useRouter().replace(pathname, { locale: nextLocale })`. +6. Author `components/app-shell/sign-out-button.tsx`. Client. `` + `useFormStatus()` to show pending state on the button. +7. Author `components/app-shell/top-bar.tsx`. Client. Renders the locale switcher + avatar dropdown (display name from `useSession()`) + sign-out button. +8. Author `components/app-shell/app-shell.tsx`. Server. Composes sidebar + top bar + `
`. +9. Author `app/[locale]/(app)/layout.tsx`. Server. `getServerSession()` → gate → render ``. +10. Author `app/[locale]/(app)/dashboard/page.tsx`. Placeholder welcome. +11. Author `app/[locale]/(app)/admin/layout.tsx`. Server. Calls `requireRole(['admin'])`. +12. i18n: add nav keys + app shell strings in `messages/{vi,en}.json`. +13. Update phase 02's `PROTECTED_PATH_PREFIXES` if needed (`/dashboard`, `/admin` already covered). +14. `pnpm typecheck` + `pnpm lint` + `pnpm build`. +15. Manual smoke: + - Unauth → `/vi/dashboard` → redirect to `/vi/sign-in?next=/vi/dashboard`. + - Admin sign-in → land on `/vi/dashboard`; sidebar shows admin items including "Admin → Invite Users". + - Receptionist sign-in (after admin invites one) → sidebar shows Dashboard, Queue, Register Patient ONLY. + - Receptionist navigates to `/vi/admin/invite` directly → redirected to `/vi/dashboard`. + +## Todo List +- [ ] shadcn primitives installed +- [ ] `lib/auth/role-menu.ts` authored — `ROLE_MENU` table +- [ ] `lib/auth/require-role.ts` authored +- [ ] `components/app-shell/{app-shell,sidebar,top-bar,locale-switcher,sign-out-button}.tsx` authored +- [ ] `(app)/layout.tsx` enforces user + role +- [ ] `(app)/dashboard/page.tsx` renders placeholder +- [ ] `(app)/admin/layout.tsx` enforces admin +- [ ] i18n `nav.*` + `app.*` keys added +- [ ] `pnpm build` green +- [ ] Manual smoke: every role's sidebar verified + +## Success Criteria +- An admin sees all admin sidebar items including `/admin/invite`. +- A non-admin (e.g., receptionist) sees only their role's items; the admin item is not present. +- A non-admin hitting `/admin/invite` URL directly → redirect to `/dashboard`. +- A signed-out user hitting `/dashboard` → redirect to `/sign-in?next=...`. +- Sign-out button clears the session; redirect to `/sign-in`. +- Locale switcher swaps `vi ↔ en` while preserving the current pathname. +- `pnpm build` green (catches `'use cache'` regressions, missing `await` on params, unawaited promises). + +## Risk Assessment + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| `getServerSession()` inadvertently cached → stale role | low | high | Helper has no `'use cache'`; documented; reviewed | +| Sidebar leaks admin items to non-admins (server-rendered with wrong role) | low | med | Role passed as prop from layout; layout fetches role server-side; no client mutation of role | +| Admin nested layout missed for a future admin route | med | med | Convention: every page under `/admin/**` is in `(app)/admin/`, which is inside the `requireRole(['admin'])` layout. PR checklist. | +| `redirect()` inside a try/catch swallows the redirect | med | high | `redirect()` only called outside try/catch in `getServerSession`/`requireRole` | +| Locale switcher doesn't preserve query params | low | low | Use `useRouter().replace(pathname, { locale })` — next-intl handles | +| Sign-out form double-submits | low | low | `useFormStatus()` disables button while pending | +| Mobile sidebar overlays content on desktop | low | low | shadcn `Sheet` is responsive; lg+ uses fixed aside | +| Adding a new role later requires touching `ROLE_MENU` AND DB enum AND types | high (when it happens) | low | One file per concern; documented as "add a role" runbook later | +| `requireRole` differs in behavior from layout-only gate → drift | low | med | Single helper called from both layout and admin actions; F-05 caller-role recheck uses it | + +## Security Considerations +- Authorization happens on the server, twice: (a) layout-level redirect, (b) RLS at DB. Client-side hiding (sidebar) is UX only. +- `useSession()` exposes `user.email` and `user.id` to the client bundle. Both are already known to the client via Supabase Auth cookies. No additional leakage. +- The dashboard placeholder shows `user.email + role`. Role is server-confirmed; no client-tampering possible. +- Admin routes are protected by the nested `(app)/admin/layout.tsx`. A direct URL fetch by a non-admin returns the dashboard via redirect — does NOT leak the existence of admin pages (the URL is still navigable; we redirect rather than 404 because a deliberate 404 leaks "this route exists with restrictions" the same way). +- Sign-out clears Supabase cookies via the SDK. Local state from `useSession()` becomes stale; phase 02's proxy redirects subsequent requests, and the SessionProvider re-renders on next layout pass. + +## Next Steps +- Phase 07 E2E exercises this layout end-to-end. +- Phase 2 features (Customers, Doctors, Services CRUD) plug into the sidebar's existing slots — `lib/auth/role-menu.ts` is the only file that needs editing to surface new pages. +- A future "deactivate user" admin flow flips a `bsk.app_users.is_active` flag (not in this phase's schema; phase 2 adds it). Layout gate would then also reject inactive users. diff --git a/scripts/check-no-secret-leak.mjs b/scripts/check-no-secret-leak.mjs index 486c848..023c00f 100644 --- a/scripts/check-no-secret-leak.mjs +++ b/scripts/check-no-secret-leak.mjs @@ -18,13 +18,23 @@ const PATTERN = String.raw`NEXT_PUBLIC_[A-Z0-9_]+\s*[=:]\s*["']?sb_secret_`; const result = spawnSync( "git", - ["grep", "-nE", PATTERN, "--", ".", ":(exclude)pnpm-lock.yaml", ":(exclude)scripts/check-no-secret-leak.mjs"], + [ + "grep", + "-nE", + PATTERN, + "--", + ".", + ":(exclude)pnpm-lock.yaml", + ":(exclude)scripts/check-no-secret-leak.mjs", + ], { encoding: "utf8" }, ); // git grep: exit 0 = matches found, 1 = no matches, other = git error if (result.status === 1) { - process.stdout.write("[check-no-secret-leak] OK — no NEXT_PUBLIC_*=sb_secret_* assignments found.\n"); + process.stdout.write( + "[check-no-secret-leak] OK — no NEXT_PUBLIC_*=sb_secret_* assignments found.\n", + ); process.exit(0); } @@ -38,5 +48,7 @@ if (result.status === 0) { process.exit(1); } -process.stderr.write(`[check-no-secret-leak] git grep failed (exit ${result.status}):\n${result.stderr}\n`); +process.stderr.write( + `[check-no-secret-leak] git grep failed (exit ${result.status}):\n${result.stderr}\n`, +); process.exit(2); diff --git a/supabase/migrations/20260525163300_bsk_init.sql b/supabase/migrations/20260525163300_bsk_init.sql new file mode 100644 index 0000000..46c576e --- /dev/null +++ b/supabase/migrations/20260525163300_bsk_init.sql @@ -0,0 +1,118 @@ +-- BSK schema initialisation +-- Creates the bsk schema, role enum, app_users enrollment table, +-- current_role() helper function, and RLS policies. +-- +-- All objects are schema-qualified to bsk.* — never public.*. +-- RLS is enabled in the same statement that creates each table (project policy). +-- +-- Idempotency: safe to re-apply on a half-applied state. +-- The app_users.user_id FK carries ON DELETE CASCADE so that removing an +-- auth.users row (account deletion) also removes the BSK enrollment row. + +-- ─── 0. Schema ─────────────────────────────────────────────────────────────── + +CREATE SCHEMA IF NOT EXISTS bsk; + +-- ─── 1. Role enum ───────────────────────────────────────────────────────────── + +DO $$ +BEGIN + CREATE TYPE bsk.app_role AS ENUM ( + 'admin', + 'doctor', + 'nurse', + 'receptionist', + 'cashier', + 'patient' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END +$$; + +-- ─── 2. Enrollment table ────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS bsk.app_users ( + user_id uuid NOT NULL PRIMARY KEY + REFERENCES auth.users(id) ON DELETE CASCADE, + role bsk.app_role NOT NULL, + full_name text, + invited_by uuid REFERENCES auth.users(id), + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE bsk.app_users IS + 'BSK enrollment gate. Existing in auth.users grants nothing — ' + 'a row here is required for any BSK access.'; + +-- ─── 3. Enable RLS (same migration, project policy) ────────────────────────── + +ALTER TABLE bsk.app_users ENABLE ROW LEVEL SECURITY; + +-- ─── 4. current_role() helper ──────────────────────────────────────────────── +-- SECURITY DEFINER so the function runs as its owner and can bypass RLS on +-- the role-lookup itself (prevents recursion). STABLE enables per-statement +-- plan caching (Supabase RLS perf pattern). SET search_path defuses the +-- standard search-path hijack against SECURITY DEFINER functions. + +CREATE OR REPLACE FUNCTION bsk.current_role() + RETURNS bsk.app_role + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = bsk, pg_catalog +AS $$ + SELECT role + FROM bsk.app_users + WHERE user_id = auth.uid(); +$$; + +COMMENT ON FUNCTION bsk.current_role() IS + 'Returns the BSK role for the currently-authenticated user, or NULL if ' + 'not enrolled. SECURITY DEFINER + STABLE: safe from search-path hijack ' + 'and eligible for per-statement plan caching by the Postgres planner.'; + +-- ─── 5. RLS policies ───────────────────────────────────────────────────────── +-- SELECT: own row OR admin. +-- INSERT / UPDATE / DELETE: no direct policy — mutations go through +-- bsk.invite_user() (phase 05) which runs as SECURITY DEFINER. + +DO $$ +BEGIN + -- Own-row select policy + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'bsk' + AND tablename = 'app_users' + AND policyname = 'app_users_select_own' + ) THEN + CREATE POLICY app_users_select_own + ON bsk.app_users + FOR SELECT + USING (user_id = auth.uid()); + END IF; + + -- Admin select policy (admin sees all rows) + IF NOT EXISTS ( + SELECT 1 FROM pg_policies + WHERE schemaname = 'bsk' + AND tablename = 'app_users' + AND policyname = 'app_users_select_admin' + ) THEN + CREATE POLICY app_users_select_admin + ON bsk.app_users + FOR SELECT + USING (bsk.current_role() = 'admin'); + END IF; +END +$$; + +-- ─── 6. Grants ──────────────────────────────────────────────────────────────── + +GRANT USAGE ON SCHEMA bsk TO anon, authenticated, service_role; + +GRANT SELECT, INSERT, UPDATE, DELETE + ON bsk.app_users + TO authenticated; + +GRANT EXECUTE ON FUNCTION bsk.current_role() TO authenticated; diff --git a/types/supabase-bsk.ts b/types/supabase-bsk.ts new file mode 100644 index 0000000..4f705ef --- /dev/null +++ b/types/supabase-bsk.ts @@ -0,0 +1,65 @@ +// PLACEHOLDER — regenerate via `pnpm db:gen-types` after running migrations. +// Hand-written to keep typecheck green until provisioning is done. +// +// Shape mirrors the canonical output of: +// supabase gen types typescript --schema bsk +// Matches migration: supabase/migrations/20260525163300_bsk_init.sql + +export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; + +export type Database = { + bsk: { + Tables: { + app_users: { + Row: { + created_at: string; + full_name: string | null; + invited_by: string | null; + role: Database["bsk"]["Enums"]["app_role"]; + user_id: string; + }; + Insert: { + created_at?: string; + full_name?: string | null; + invited_by?: string | null; + role: Database["bsk"]["Enums"]["app_role"]; + user_id: string; + }; + Update: { + created_at?: string; + full_name?: string | null; + invited_by?: string | null; + role?: Database["bsk"]["Enums"]["app_role"]; + user_id?: string; + }; + Relationships: [ + { + foreignKeyName: "app_users_invited_by_fkey"; + columns: ["invited_by"]; + isOneToOne: false; + referencedRelation: "users"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "app_users_user_id_fkey"; + columns: ["user_id"]; + isOneToOne: true; + referencedRelation: "users"; + referencedColumns: ["id"]; + }, + ]; + }; + }; + Views: Record; + Functions: { + current_role: { + Args: Record; + Returns: Database["bsk"]["Enums"]["app_role"] | null; + }; + }; + Enums: { + app_role: "admin" | "doctor" | "nurse" | "receptionist" | "cashier" | "patient"; + }; + CompositeTypes: Record; + }; +};