mirror of
https://github.com/tiennm99/bsk.git
synced 2026-10-03 07:12:12 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user