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)
This commit is contained in:
2026-05-25 17:23:08 +07:00
parent 2d5e4e92f9
commit 0a08f80450
9 changed files with 782 additions and 3 deletions
+4
View File
@@ -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.
+31
View File
@@ -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);
}
+1
View File
@@ -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": {
@@ -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<string>` = `['/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 `<SessionProvider user={user}>` 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 <NextIntlClientProvider>
<SessionProvider user={user}>
{children}
</SessionProvider>
</NextIntlClientProvider>
```
## 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 `<SessionProvider>`.
### 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<User | null>`, `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 `<SessionProvider user={user}>`. 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.
@@ -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 `<form action>` 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 `<form action={dispatchAction}>` 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 `<input name="next">` 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 `<SignInForm next={next} />` (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 `<form action={dispatchAction}>`. 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 `<Toaster />` 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
<form action={dispatchAction}>
│ (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 `<Toaster />` 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 `<Toaster richColors position="top-right" />` 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 `<SignInForm next={searchParams.next ?? ''} />`.
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: `<form action={dispatchAction}>` containing email + password + hidden `next` + submit button + form-level error region.
6. Verify the form action passes through correctly: `<input name="email">`, `<input name="password" type="password">`, `<input type="hidden" name="next" value={next}>`. 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 + `<input name>` 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 |
| `<Toaster />` 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.
@@ -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()`, `<SessionProvider>`, `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 `<SessionProvider>` 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: `<AppShell user={user} role={role}>{children}</AppShell>`.
- F-06-2: `components/app-shell/app-shell.tsx` — server component. Composes `<Sidebar>` + `<TopBar>` + `<main>`. Receives `user`, `role` as props.
- F-06-3: `components/app-shell/sidebar.tsx` — server component. Reads `role` prop, looks up `ROLE_MENU[role]`, renders `<Link>` 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<AppRole, MenuItem[]>` 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: `<form action={signOutAction}><button>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 `<form action>` 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. `<form action={signOutAction}>` + `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 + `<main className="flex-1">`.
9. Author `app/[locale]/(app)/layout.tsx`. Server. `getServerSession()` → gate → render `<AppShell>`.
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.
+15 -3
View File
@@ -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);
@@ -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;
+65
View File
@@ -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<string, never>;
Functions: {
current_role: {
Args: Record<string, never>;
Returns: Database["bsk"]["Enums"]["app_role"] | null;
};
};
Enums: {
app_role: "admin" | "doctor" | "nurse" | "receptionist" | "cashier" | "patient";
};
CompositeTypes: Record<string, never>;
};
};