diff --git a/app/[locale]/(app)/admin/invite/actions.ts b/app/[locale]/(app)/admin/invite/actions.ts new file mode 100644 index 0000000..82a6e35 --- /dev/null +++ b/app/[locale]/(app)/admin/invite/actions.ts @@ -0,0 +1,80 @@ +"use server"; + +/** + * Server Action for admin user-invite flow. + * + * Security notes: + * - Caller-role check via getServerSession() is defense-in-depth. + * The (app)/admin layout (phase 06) enforces the admin gate at the route + * level; this check ensures the action itself cannot be called by a + * non-admin even if the layout is bypassed (e.g. direct fetch). + * - The admin client (service-role key) is used for both auth.admin.inviteUserByEmail + * AND the bsk.app_users insert, because that table has no INSERT RLS policy + * by design — only privileged writes are allowed. + * - inviteUserByEmail is idempotent for existing auth.users rows: it resends + * an invite / password-reset link. We still insert the bsk.app_users row + * to enroll them in BSK. If the insert fails due to a duplicate key (user + * was already enrolled), we surface errorEmailTaken. + */ + +import { getTranslations } from "next-intl/server"; + +import { getServerSession } from "@/lib/auth/get-server-session"; +import { createSupabaseAdminClient } from "@/lib/supabase/admin"; +import { InviteUserSchema, type InviteUserState } from "@/lib/auth/invite-schema"; + +export async function inviteUserAction( + _prevState: InviteUserState, + formData: FormData, +): Promise { + const t = await getTranslations("admin.invite"); + + // ── Caller-role check (defense-in-depth) ────────────────────────────────── + const session = await getServerSession(); + if (!session || session.role !== "admin") { + return { status: "error", fieldErrors: {}, formError: t("errorForbidden") }; + } + + // ── Input validation ─────────────────────────────────────────────────────── + const parsed = InviteUserSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) { + const flat = parsed.error.flatten(); + return { + status: "error", + fieldErrors: flat.fieldErrors as Record, + formError: null, + }; + } + + const { email, role } = parsed.data; + const supabaseAdmin = createSupabaseAdminClient(); + + // ── Create / re-invite the auth.users row ────────────────────────────────── + const { data: inviteData, error: inviteError } = + await supabaseAdmin.auth.admin.inviteUserByEmail(email); + + if (inviteError || !inviteData.user) { + return { status: "error", fieldErrors: {}, formError: t("errorGeneric") }; + } + + const newUserId = inviteData.user.id; + + // ── Enroll in bsk.app_users (admin client bypasses RLS by design) ────────── + const { error: enrollError } = await supabaseAdmin.from("app_users").insert({ + user_id: newUserId, + role, + invited_by: session.user.id, + }); + + if (enrollError) { + // Postgres unique-violation code 23505 → user already enrolled. + const isEmailTaken = enrollError.code === "23505"; + return { + status: "error", + fieldErrors: {}, + formError: isEmailTaken ? t("errorEmailTaken") : t("errorGeneric"), + }; + } + + return { status: "success", invitedEmail: email }; +} diff --git a/app/[locale]/(app)/admin/invite/invite-user-form.tsx b/app/[locale]/(app)/admin/invite/invite-user-form.tsx new file mode 100644 index 0000000..b1bf3bf --- /dev/null +++ b/app/[locale]/(app)/admin/invite/invite-user-form.tsx @@ -0,0 +1,152 @@ +"use client"; + +/** + * Invite-user form — Client Component. + * + * Wiring: RHF owns client-side validation; useActionState dispatches the + * native form action to inviteUserAction. Same pattern as sign-in-form.tsx. + * + * On success the form shows a confirmation message and resets. + * On error the formError is displayed below the submit button. + * + * Email-delivery caveat (free-tier SMTP): if the invited user does not + * receive the email within 5 minutes, the admin can copy the invite link + * from the Supabase dashboard under Authentication → Users. + */ + +import { useActionState, useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useTranslations } from "next-intl"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { appRoles } from "@/lib/db/roles"; +import { + InviteUserSchema, + type InviteUserInput, + type InviteUserState, +} from "@/lib/auth/invite-schema"; +import { inviteUserAction } from "./actions"; + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function InviteUserForm() { + const t = useTranslations("admin.invite"); + + const [state, dispatchAction, isPending] = useActionState( + inviteUserAction, + { status: "idle" }, + ); + + const form = useForm({ + resolver: zodResolver(InviteUserSchema), + mode: "onBlur", + defaultValues: { email: "", role: "patient" }, + }); + + const { errors: fieldErrors } = form.formState; + + // Sync server field errors into RHF so inline error UI is consistent. + useEffect(() => { + if (state.status !== "error") return; + const serverErrors = state.fieldErrors; + if (serverErrors.email?.length) { + form.setError("email", { message: serverErrors.email[0] }); + } + if (serverErrors.role?.length) { + form.setError("role", { message: serverErrors.role[0] }); + } + }, [state, form]); + + // Reset form after a successful invite. + useEffect(() => { + if (state.status === "success") { + form.reset(); + } + }, [state, form]); + + const formError = state.status === "error" && state.formError ? state.formError : null; + + return ( +
+ {/* Success banner */} + {state.status === "success" && ( +

+ {t("success", { email: state.invitedEmail })} +

+ )} + +
+ {/* Email */} +
+ + + {fieldErrors.email && ( + + )} +
+ + {/* Role */} +
+ + {/* + Native + {appRoles.map((r) => ( + + ))} + + {fieldErrors.role && ( + + )} +
+ + {/* Form-level error */} + {formError && ( +

+ {formError} +

+ )} + + +
+
+ ); +} diff --git a/app/[locale]/(app)/admin/invite/page.tsx b/app/[locale]/(app)/admin/invite/page.tsx new file mode 100644 index 0000000..9563921 --- /dev/null +++ b/app/[locale]/(app)/admin/invite/page.tsx @@ -0,0 +1,24 @@ +/** + * Admin invite page — Server Component. + * + * Phase 06 will install the (app)/admin layout that enforces role='admin' + * at the route level. Until then, defense-in-depth lives inside + * inviteUserAction itself (caller-role check via getServerSession). + * + * This page intentionally does NOT redirect non-admins — that is the layout's + * responsibility (phase 06). The action rejects unauthorized submissions. + */ + +import { getTranslations } from "next-intl/server"; +import { InviteUserForm } from "./invite-user-form"; + +export default async function AdminInvitePage() { + const t = await getTranslations("admin.invite"); + + return ( +
+

{t("title")}

+ +
+ ); +} diff --git a/app/[locale]/(auth)/sign-in/actions.ts b/app/[locale]/(auth)/sign-in/actions.ts index 9d472b3..e4c1d5c 100644 --- a/app/[locale]/(auth)/sign-in/actions.ts +++ b/app/[locale]/(auth)/sign-in/actions.ts @@ -70,20 +70,47 @@ export async function signInAction( // Verify the authenticated user has a row in bsk.app_users. Users who exist // in auth.users but have never been enrolled by an admin must be rejected. // We return the SAME generic error as wrong-password (enumeration defense). - const { data: enrollment } = await supabase + let { data: enrollment } = await supabase .from("app_users") .select("role") .eq("user_id", user.id) .maybeSingle(); if (!enrollment) { - // Sign out so the session cookie is not left in a half-authenticated state. - await supabase.auth.signOut(); - return { - status: "error", - fieldErrors: {}, - formError: t("invalidCredentials"), - }; + // First-user-becomes-admin: race-safe claim via SECURITY DEFINER function + // that holds pg_advisory_xact_lock and EXISTS-guards the INSERT. + // We check count first to avoid calling the RPC when other users exist + // (an unenrolled non-first user must be rejected without any promotion). + const { count: existingCount } = await supabase + .from("app_users") + .select("user_id", { count: "exact", head: true }); + + if (existingCount === 0) { + const { data: claimed } = await supabase.rpc("claim_first_admin", { + p_user_id: user.id, + }); + + if (claimed === true) { + // Re-fetch enrollment now that the row exists (role = 'admin'). + const { data: refetched } = await supabase + .from("app_users") + .select("role") + .eq("user_id", user.id) + .maybeSingle(); + enrollment = refetched; + } + } + + // If enrollment is still null after the claim attempt (race lost, count > 0, + // or RPC returned false), sign out and return a generic error. + if (!enrollment) { + await supabase.auth.signOut(); + return { + status: "error", + fieldErrors: {}, + formError: t("invalidCredentials"), + }; + } } // Step 4 — redirect to dashboard diff --git a/docs/runbooks/first-admin-setup.md b/docs/runbooks/first-admin-setup.md new file mode 100644 index 0000000..78155c2 --- /dev/null +++ b/docs/runbooks/first-admin-setup.md @@ -0,0 +1,45 @@ +# First-Admin Setup Runbook + +## Happy Path (automatic) + +The first person to successfully sign in when `bsk.app_users` is empty +is automatically granted the `admin` role. + +Flow (implemented in `signInAction` + migration `20260525163400_bsk_admin.sql`): + +1. User signs in with valid credentials. +2. `signInAction` checks for an enrollment row in `bsk.app_users`. +3. If no row exists, it reads `COUNT(*)` from `bsk.app_users`. +4. If count is 0, it calls `bsk.claim_first_admin(user_id)`. +5. The SQL function acquires `pg_advisory_xact_lock(hashtext('bsk:claim_first_admin')::bigint)`, + then inserts `(user_id, 'admin')` only if the table is still empty. +6. Returns `true` → sign-in proceeds as admin. Returns `false` (race lost) → generic error. + +Two simultaneous first sign-ins: exactly one succeeds; the other receives +"Invalid email or password" and can retry (their next sign-in will find +count > 0 and be rejected as unenrolled until an admin invites them). + +## Manual Fallback (psql) + +Use this if the automatic claim ever fails or you need to bootstrap +a specific user directly. + +```sql +-- 1. Find the user's UUID in auth.users +SELECT id, email FROM auth.users WHERE email = 'your@email.com'; + +-- 2. Insert the admin enrollment row +INSERT INTO bsk.app_users (user_id, role) +VALUES ('', 'admin'); +``` + +Run via Supabase dashboard SQL editor or `psql` with the connection string +from your Supabase project settings. + +Note: deleting all rows from `bsk.app_users` effectively resets the +bootstrap — the next sign-in will claim admin again. + +## Reference + +Migration: `supabase/migrations/20260525163400_bsk_admin.sql` +Sign-in action: `app/[locale]/(auth)/sign-in/actions.ts` diff --git a/lib/auth/invite-schema.ts b/lib/auth/invite-schema.ts new file mode 100644 index 0000000..6c41e56 --- /dev/null +++ b/lib/auth/invite-schema.ts @@ -0,0 +1,40 @@ +/** + * Zod schema and state types for the admin invite flow. + * + * No `'use server'` — framework-agnostic so it can be imported by both the + * Server Action (validation) and the Client Component (RHF resolver). + */ + +import { z } from "zod"; +import { appRoles } from "@/lib/db/roles"; + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +export const InviteUserSchema = z.object({ + email: z.string().email(), + role: z.enum(appRoles), +}); + +export type InviteUserInput = z.infer; + +// --------------------------------------------------------------------------- +// Discriminated-union state — returned by inviteUserAction, consumed by +// useActionState. All variants must be JSON-serializable. +// --------------------------------------------------------------------------- + +export type InviteUserState = + | { status: "idle" } + | { + status: "error"; + /** Per-field validation errors keyed by field name. */ + fieldErrors: Record; + /** Non-field error (forbidden, email taken, server error). Null when fieldErrors are set. */ + formError: string | null; + } + | { + status: "success"; + /** The email address of the newly invited user. */ + invitedEmail: string; + }; diff --git a/messages/en.json b/messages/en.json index 2d41bb3..db43166 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8,6 +8,19 @@ "notFoundTitle": "Page not found", "notFoundBody": "The path you requested does not exist." }, + "admin": { + "invite": { + "title": "Invite User", + "emailLabel": "Email address", + "roleLabel": "Role", + "submit": "Send invite", + "submitting": "Sending…", + "success": "Invited {email}. If the email does not arrive within 5 minutes, copy the invite link from the Supabase dashboard under Authentication → Users.", + "errorForbidden": "You do not have permission to invite users.", + "errorEmailTaken": "This user is already enrolled in BSK.", + "errorGeneric": "Something went wrong. Please try again." + } + }, "auth": { "signIn": { "title": "Sign in to BSK", diff --git a/messages/vi.json b/messages/vi.json index a3a2eec..ef74c1d 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -8,6 +8,19 @@ "notFoundTitle": "Không tìm thấy trang", "notFoundBody": "Đường dẫn bạn truy cập không tồn tại." }, + "admin": { + "invite": { + "title": "Mời người dùng", + "emailLabel": "Địa chỉ email", + "roleLabel": "Vai trò", + "submit": "Gửi lời mời", + "submitting": "Đang gửi…", + "success": "Đã mời {email}. Nếu email không đến trong vòng 5 phút, hãy sao chép liên kết mời từ bảng điều khiển Supabase tại Authentication → Users.", + "errorForbidden": "Bạn không có quyền mời người dùng.", + "errorEmailTaken": "Người dùng này đã được đăng ký trong BSK.", + "errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại." + } + }, "auth": { "signIn": { "title": "Đăng nhập BSK", diff --git a/supabase/migrations/20260525163400_bsk_admin.sql b/supabase/migrations/20260525163400_bsk_admin.sql new file mode 100644 index 0000000..a848063 --- /dev/null +++ b/supabase/migrations/20260525163400_bsk_admin.sql @@ -0,0 +1,50 @@ +-- BSK admin bootstrap: claim_first_admin function. +-- Strategy A: advisory lock + EXISTS-guarded INSERT (serializes concurrent +-- first-sign-in race without the "only one admin ever" constraint of Strategy B). +-- +-- Advisory-lock key derivation: +-- SELECT hashtext('bsk:claim_first_admin')::bigint +-- => The key is a stable 64-bit integer derived from the function's fully- +-- qualified logical name. If you need the exact value for cross-session +-- debugging, run that SELECT in psql — hashtext is deterministic across +-- all PG versions ≥ 9.6. Using hashtext keeps the value reproducible +-- without hard-coding a magic number. + +CREATE OR REPLACE FUNCTION bsk.claim_first_admin(p_user_id uuid) + RETURNS boolean + LANGUAGE plpgsql + VOLATILE -- writes a row; must NOT be STABLE/IMMUTABLE + SECURITY DEFINER -- runs as function owner (bypasses caller RLS) + SET search_path = bsk, pg_catalog +AS $$ +DECLARE + v_inserted boolean := false; +BEGIN + -- Acquire an exclusive transaction-level advisory lock keyed by + -- hashtext('bsk:claim_first_admin')::bigint. + -- This serializes concurrent callers: the second caller blocks here until + -- the first transaction commits/rolls back, by which point bsk.app_users + -- is no longer empty and the EXISTS guard below returns false. + PERFORM pg_advisory_xact_lock(hashtext('bsk:claim_first_admin')::bigint); + + -- EXISTS-guarded INSERT: only insert when the table is empty. + -- The advisory lock above ensures atomicity across concurrent transactions. + INSERT INTO bsk.app_users (user_id, role) + SELECT p_user_id, 'admin'::bsk.app_role + WHERE NOT EXISTS (SELECT 1 FROM bsk.app_users); + + GET DIAGNOSTICS v_inserted = ROW_COUNT; + + RETURN v_inserted; +END; +$$; + +COMMENT ON FUNCTION bsk.claim_first_admin(uuid) IS + 'Race-safe first-admin bootstrap. Acquires pg_advisory_xact_lock keyed by ' + 'hashtext(''bsk:claim_first_admin'')::bigint, then inserts (user_id, admin) ' + 'into bsk.app_users only when the table is empty. Returns true if this caller ' + 'claimed admin, false if another caller beat the race. ' + 'Called from signInAction when bsk.app_users has zero rows.'; + +-- Grant EXECUTE to authenticated only (anon cannot trigger first-admin claim). +GRANT EXECUTE ON FUNCTION bsk.claim_first_admin(uuid) TO authenticated; diff --git a/types/supabase-bsk.ts b/types/supabase-bsk.ts index 4f705ef..dbd2dca 100644 --- a/types/supabase-bsk.ts +++ b/types/supabase-bsk.ts @@ -56,6 +56,10 @@ export type Database = { Args: Record; Returns: Database["bsk"]["Enums"]["app_role"] | null; }; + claim_first_admin: { + Args: { p_user_id: string }; + Returns: boolean; + }; }; Enums: { app_role: "admin" | "doctor" | "nurse" | "receptionist" | "cashier" | "patient";