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.
This commit is contained in:
tiennm99 committed 2026-05-25 17:47:47 +07:00
1 parent 8265942101
commit eb1af9013e
10 files changed
+456 -8

No files matched your search

@@ -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<InviteUserState> {
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<string, string[]>,
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 };
}
@@ -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<InviteUserState, FormData>(
inviteUserAction,
{ status: "idle" },
);
const form = useForm<InviteUserInput>({
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 (
<div className="space-y-6">
{/* Success banner */}
{state.status === "success" && (
<p className="text-sm font-medium text-green-600" role="status">
{t("success", { email: state.invitedEmail })}
</p>
)}
<form action={dispatchAction} noValidate className="space-y-4">
{/* Email */}
<div className="space-y-1.5">
<Label htmlFor="invite-email">{t("emailLabel")}</Label>
<Input
id="invite-email"
type="email"
autoComplete="off"
autoCapitalize="none"
spellCheck={false}
disabled={isPending}
aria-invalid={!!fieldErrors.email}
aria-describedby={fieldErrors.email ? "invite-email-error" : undefined}
{...form.register("email")}
/>
{fieldErrors.email && (
<p id="invite-email-error" className="text-destructive text-sm" role="alert">
{fieldErrors.email.message}
</p>
)}
</div>
{/* Role */}
<div className="space-y-1.5">
<Label htmlFor="invite-role">{t("roleLabel")}</Label>
{/*
Native <select> used intentionally to avoid pulling in the shadcn
Select primitive (extra scope). Can be swapped in phase 06+ if
design system requires it.
*/}
<select
id="invite-role"
className="border-input bg-background text-foreground focus:ring-ring w-full rounded-md border px-3 py-2 text-sm focus:ring-2 focus:outline-none disabled:opacity-50"
disabled={isPending}
aria-invalid={!!fieldErrors.role}
aria-describedby={fieldErrors.role ? "invite-role-error" : undefined}
{...form.register("role")}
>
{appRoles.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
{fieldErrors.role && (
<p id="invite-role-error" className="text-destructive text-sm" role="alert">
{fieldErrors.role.message}
</p>
)}
</div>
{/* Form-level error */}
{formError && (
<p className="text-destructive text-sm" role="alert">
{formError}
</p>
)}
<Button
type="submit"
className="w-full"
disabled={isPending || (!form.formState.isValid && form.formState.isDirty)}
aria-disabled={isPending}
>
{isPending ? t("submitting") : t("submit")}
</Button>
</form>
</div>
);
}
+24
View File
@@ -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 (
<main className="mx-auto max-w-md px-4 py-12">
<h1 className="text-foreground mb-6 text-xl font-semibold">{t("title")}</h1>
<InviteUserForm />
</main>
);
}
+35 -8
View File
@@ -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
+45
View File
@@ -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 ('<uuid-from-step-1>', '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`
+40
View File
@@ -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<typeof InviteUserSchema>;
// ---------------------------------------------------------------------------
// 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<string, string[]>;
/** 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;
};
+13
View File
@@ -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",
+13
View File
@@ -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",
@@ -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;
+4
View File
@@ -56,6 +56,10 @@ export type Database = {
Args: Record<string, never>;
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";