mirror of
https://github.com/tiennm99/bsk.git
synced 2026-09-04 02:17:36 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user