Files
teatea-pension/modules/auth/components/AuthPanel.tsx

273 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { FormEvent, Suspense, useEffect, useMemo, useState } from "react";
import { ArrowRight, Building2, ShieldCheck, Stethoscope } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import type { ApiResult } from "@/modules/core/server/api";
import type { Organization } from "@/modules/core/types";
type AuthMode = "login" | "register" | "setup";
type AuthPanelProps = {
mode: AuthMode;
};
const modeCopy: Record<AuthMode, { title: string; badge: string; submit: string }> = {
login: { title: "登录", badge: "Welcome back", submit: "进入系统" },
register: { title: "注册账号", badge: "Staff account", submit: "创建账号" },
setup: { title: "初始化机构", badge: "First run", submit: "完成初始化" },
};
function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
const router = useRouter();
const searchParams = useSearchParams();
const redirectTo = searchParams.get("redirectTo") ?? "/app/dashboard";
const invitationToken = searchParams.get("invite") ?? "";
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const [hasAccounts, setHasAccounts] = useState<boolean | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const copy = modeCopy[mode];
const isSetup = mode === "setup";
const isLogin = mode === "login";
useEffect(() => {
let isMounted = true;
async function loadState(): Promise<void> {
const [bootstrapResponse, sessionResponse, organizationsResponse] = await Promise.all([
fetch("/api/auth/bootstrap"),
fetch("/api/auth/session"),
fetch("/api/organizations"),
]);
const bootstrap = (await bootstrapResponse.json()) as ApiResult<{ setupRequired: boolean }>;
const session = (await sessionResponse.json()) as ApiResult<{ account: unknown | null }>;
const organizationsResult = (await organizationsResponse.json()) as ApiResult<{ organizations: Organization[] }>;
if (!isMounted || !bootstrap.success || !session.success) {
return;
}
if (organizationsResult.success) {
setOrganizations(organizationsResult.organizations);
}
const initialized = !bootstrap.setupRequired;
setHasAccounts(initialized);
if (mode !== "setup" && !initialized) {
router.replace("/setup");
return;
}
if (mode === "setup" && initialized) {
router.replace("/login");
return;
}
if (session.account) {
router.replace("/app/dashboard");
}
}
void loadState();
return () => {
isMounted = false;
};
}, [mode, router]);
const secondaryAction = useMemo(() => {
if (isSetup) {
return null;
}
if (isLogin) {
return { href: "/register", label: "注册" };
}
return { href: "/login", label: "登录" };
}, [isLogin, isSetup]);
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setMessage("");
setIsPending(true);
const formData = new FormData(event.currentTarget);
const name = String(formData.get("name") ?? "");
const email = String(formData.get("email") ?? "");
const password = String(formData.get("password") ?? "");
const organization = String(formData.get("organization") ?? "");
const organizationId = String(formData.get("organizationId") ?? "");
const endpoint = isLogin ? "/api/auth/login" : isSetup ? "/api/auth/setup" : "/api/auth/register";
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name,
email,
password,
organization,
organizationId,
invitationToken,
}),
});
const result = (await response.json()) as ApiResult<{ account: unknown; pendingApproval?: boolean }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
if (result.pendingApproval) {
setMessage(result.reason);
return;
}
router.refresh();
router.replace(isLogin ? redirectTo : "/app/dashboard");
}
if (hasAccounts === null) {
return <div className="h-96" />;
}
return (
<main className="grid min-h-svh bg-background lg:grid-cols-[0.95fr_1.05fr]">
<section className="hidden border-r bg-primary text-primary-foreground lg:flex lg:flex-col lg:justify-between lg:p-10">
<Link href="/login" className="inline-flex items-center gap-3 text-sm font-medium">
<span className="inline-flex size-10 items-center justify-center rounded-md bg-white/12">
<Stethoscope className="size-5" aria-hidden="true" />
</span>
TeaTea
</Link>
<div className="max-w-md">
<Badge className="bg-white/12 text-primary-foreground">{copy.badge}</Badge>
<h1 className="mt-5 text-5xl font-semibold tracking-normal">{copy.title}</h1>
<p className="mt-5 text-sm leading-6 text-primary-foreground/72">
</p>
</div>
<div className="grid grid-cols-3 gap-3 text-xs text-primary-foreground/72">
<span></span>
<span></span>
<span></span>
</div>
</section>
<section className="flex min-h-svh items-center justify-center px-5 py-10">
<div className="w-full max-w-sm">
<div className="mb-8 flex items-center justify-between lg:hidden">
<Link href="/login" className="inline-flex items-center gap-2 text-sm font-semibold">
<Stethoscope className="size-5 text-primary" aria-hidden="true" />
TeaTea
</Link>
<Badge variant="secondary">{copy.badge}</Badge>
</div>
<Card className="border bg-white/82 p-6 shadow-none backdrop-blur">
<div className="mb-6">
<div className="inline-flex size-11 items-center justify-center rounded-md bg-secondary text-primary">
{isSetup ? (
<Building2 className="size-5" aria-hidden="true" />
) : (
<ShieldCheck className="size-5" aria-hidden="true" />
)}
</div>
<h2 className="mt-5 text-2xl font-semibold">{copy.title}</h2>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
{isSetup ? (
<label className="block space-y-2">
<span className="text-sm font-medium"></span>
<Input name="organization" autoComplete="organization" required />
</label>
) : null}
{mode === "register" && invitationToken ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground">
使
</p>
) : null}
{mode === "register" && !invitationToken && organizations.length > 0 ? (
<label className="block space-y-2">
<span className="text-sm font-medium"></span>
<select
className="h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
name="organizationId"
>
<option value=""></option>
{organizations.map((organization) => (
<option key={organization.id} value={organization.id}>
{organization.name}
</option>
))}
</select>
</label>
) : null}
{!isLogin ? (
<label className="block space-y-2">
<span className="text-sm font-medium"></span>
<Input name="name" autoComplete="name" required />
</label>
) : null}
<label className="block space-y-2">
<span className="text-sm font-medium"></span>
<Input name="email" type="email" autoComplete="email" required />
</label>
<label className="block space-y-2">
<span className="text-sm font-medium"></span>
<Input name="password" type="password" autoComplete="current-password" required />
</label>
{message ? (
<p className="rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive" role="alert">
{message}
</p>
) : null}
<Button className="w-full" type="submit" disabled={isPending}>
{copy.submit}
<ArrowRight className="size-4" aria-hidden="true" />
</Button>
</form>
{secondaryAction ? (
<div className="mt-5 text-center text-sm text-muted-foreground">
<Link className="font-medium text-primary hover:underline" href={secondaryAction.href}>
{secondaryAction.label}
</Link>
</div>
) : null}
</Card>
</div>
</section>
</main>
);
}
export function AuthPanel({ mode }: AuthPanelProps): React.ReactElement {
return (
<Suspense fallback={<main className="min-h-svh bg-background" />}>
<AuthPanelContent mode={mode} />
</Suspense>
);
}