Commit Graph
9 Commits
Author SHA1 Message Date
tiennm99 e2fb601b2a feat(doctors): admin doctor management (Phase 2 slice)
- bsk.doctors table (soft-delete) with RLS: reads for enrolled staff,
  writes admin-only; user-client writes so RLS is the enforcement point
- add / edit / deactivate Server Actions — admin-gated, Zod-validated,
  audit-logged via log_audit, revalidate the list
- doctors admin page (RSC list + inline edit + deactivate) and add form
- sidebar nav entry + vi/en strings

Establishes the Phase 2 CRUD pattern (RLS gate + defense-in-depth role
check + Zod + audit + revalidate) for the remaining core entities.
2026-07-25 01:57:01 +07:00
tiennm99 164c6125c2 feat(auth): allowlist-gated admin bootstrap, rate limiting, audit log
- claim_first_admin: no-arg, inserts auth.uid(), gated on an email allowlist
  table so an arbitrary shared-pool principal can no longer claim admin
- revoke direct writes on app_users from authenticated (least privilege)
- rate-limit sign-in (by platform IP) and invite (by admin id); fail open
  on Redis outage so an outage cannot lock staff out
- audit_log table + SECURITY DEFINER log_audit writer, admin-only reads
- invite: map existing-email to a clear error, roll back orphaned auth row
- session: read role + full_name in one own-row query
2026-07-25 01:51:35 +07:00
tiennm99 9afd68a741 feat(phase-1): role-gated app shell + dashboard placeholder
- app/[locale]/(app)/layout.tsx: Server gate — getServerSession()
  redirects unauth users (defense in depth) and signs out + redirects
  the authed-but-no-role edge case. Renders AppShell with user/role/locale.
- app/[locale]/(app)/admin/layout.tsx: second gate via requireRole;
  non-admin → /[locale]/dashboard (not 404 — avoids confirming routes).
- app/[locale]/(app)/dashboard/page.tsx: placeholder showing email +
  role badge.
- lib/auth/role-menu.ts: ROLE_MENU mapping per AppRole → MenuItem[]
  with href + i18n labelKey + lucide icon.
- lib/auth/require-role.ts: server helper for the admin gate.
- components/app-shell/{app-shell,sidebar,sign-out-button,locale-switcher}.tsx:
  Server-rendered shell + sidebar that reads ROLE_MENU[role]; client
  locale switcher (native <select> fallback) and sign-out (<form action>
  with useFormStatus pending UX).
- components/ui/{badge,separator}.tsx: shadcn primitives.
- messages/{vi,en}.json: nav.*, app.*, dashboard.* keys (parity).

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

No audit_log refs — trimmed plan respected.
2026-05-25 17:47:47 +07:00
tiennm99 e514d710c4 feat(phase-1): sign-in/sign-out server actions + zod schema
- lib/auth/schemas.ts: SignInSchema (Zod v4, email + password 8-72),
  SignInState discriminated union (idle | error), parseSignIn helper.
  Framework-agnostic — no 'use server' so client RHF resolver can import.
- app/[locale]/(auth)/sign-in/actions.ts: signInAction (RHF/useActionState
  shape) + signOutAction. Flow: safeParse → signInWithPassword → enrollment
  check via bsk.app_users → redirect to /[locale]/dashboard. Wrong-password
  and unenrolled paths return the SAME generic error (auth.users
  enumeration defense). redirect() called outside try/catch per
  React 19 / Next 16 semantics.
- messages/{vi,en}.json: auth.signIn.* keys in parity.

Scope honored: no rate limit, no next= guard, no IP extraction.
2026-05-25 17:36:09 +07:00
tiennm99 129cbb7bf0 feat(phase-1): auth session wiring (proxy + layout + helpers)
- proxy.ts: composes Supabase session refresh + next-intl middleware
  into a single NextResponse via copyCookies helper. Coarse auth gate
  on /dashboard + /admin prefixes redirects unauth users to
  /[locale]/sign-in (no ?next= per trimmed plan).
- lib/supabase/session.ts: implements updateSupabaseSession() returning
  { response, user }. Cookies written onto both request.cookies (for
  downstream reads) and response.cookies (for browser). PROTECTED_PATH_PREFIXES
  exported as the gate list.
- lib/proxy/copy-cookies.ts: small helper that ports Set-Cookie entries
  between two NextResponses.
- lib/auth/get-server-session.ts: getServerSession() returning
  { user, role } | null. Derives User type from the factory's return
  type so @supabase/supabase-js stays out of allow-listed lib/auth/*
  per ESLint no-restricted-imports.
- lib/auth/session-provider.tsx: client-side context exposing user to
  client components via useSession() — populated once per request in
  the locale layout.
- app/[locale]/layout.tsx: reads user via getUser() outside any
  'use cache' scope; wraps children in SessionProvider; explicit
  'use cache' warning comment.
2026-05-25 17:30:41 +07:00
tiennm99 0a08f80450 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)
2026-05-25 17:23:08 +07:00
tiennm99 10a3693f1b fix(scaffold): apply phase-0 review findings
- env: cross-check VERCEL_ENV against NEXT_PUBLIC_APP_ENV at boot so prod
  credentials cannot silently write into a dev keyspace
- upstash: tighten cache-key regex (kebab + colon only); split SCAN
  patterns into their own validator so glob '*' is allowed only there
- eslint: forbid raw @upstash/redis, @upstash/ratelimit, @supabase/supabase-js
  imports outside the named factory files
- supabase/admin: harmonize 'use cache' guidance with CONTRIBUTING.md
  (safe inside cache; partition key on identity for user-specific reads)
- app/layout: clarify global-error.tsx vs error.tsx shell requirements
  given the passthrough root layout
- readme: Next.js 15 -> 16 (matches scaffolded version)
2026-05-25 15:56:54 +07:00
tiennm99 b88147059e feat: scaffold Phase 0 foundation (Next.js 16 + Tailwind v4 + Supabase/Upstash)
Initial code drop for the BSK educational rewrite. Repo previously held only
docs (PLAN.md, README, NOTICE, LICENSE, RESEARCH_REPORT). This commit lands
the App Router shell, i18n route group, and shared-infra factories per the
Phase 0 plan.

Scaffold:
- Next.js 16 + React 19 + TypeScript 5.9, App Router, Turbopack defaults
- Tailwind v4 via @tailwindcss/postcss with CSS-first @theme block
- shadcn/ui CLI v4 (components.json + cn helper); components install lazily
- next-intl v4 with vi default + en fallback; async-params-aware routing
- proxy.ts (Next 16's renamed middleware) wired to next-intl
- lib/supabase/{server,client,admin,session}.ts on @supabase/ssr, schema-scoped
  to 'bsk', async cookies(), server factory unsafe inside 'use cache'
- lib/upstash.ts: prefixed cache helpers and Ratelimit v2, QStash signature
  verifier; future code cannot write unprefixed Redis keys
- lib/env/{client,server}.ts split so the secret key types stay server-side
- ESLint flat config (eslint-config-next/core-web-vitals + typescript +
  prettier), Prettier with tailwindcss plugin, .npmrc + pnpm-workspace.yaml
  for pnpm 11 native-build approval
- CI runs format:check, lint, typecheck, build on PR with dummy env

PLAN.md updates:
- §1 reconciled to TypeScript 5.9 (TS 6 is GA but lacks ecosystem support)
- §3.1 notes middleware → proxy file rename and removal of `next lint`

All four gates pass locally: format:check, lint, typecheck, build (SSG for
/vi and /en, Proxy detected). Code-reviewer findings applied: env split,
session helper renamed and docstring fixed, cache.set/del types tightened,
prettierignore scope reduced, bilingual GlobalNotFound, explanatory comments
on no-op layouts and duplicate setRequestLocale.

Deferred to Phase 1: wiring updateSupabaseSession into proxy.ts (needs auth
flow first), schema migrations, sign-in form.
2026-05-25 10:58:04 +07:00