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

316 lines
11 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 { Select } from "@/components/ui/select";
import type { ApiResult } from "@/modules/core/server/api";
import type { AccountOrganizationOption, Organization } from "@/modules/core/types";
import { getWorkspaceHref, getWorkspacePathFromPathname } from "@/modules/shared/lib/workspace-routing";
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: "完成初始化" },
};
async function readApiResult<T extends Record<string, unknown>>(response: Response): Promise<ApiResult<T>> {
const text = await response.text();
if (!text) {
return {
success: false,
reason: response.ok ? "服务器返回了空响应" : `请求失败:${response.status}`,
};
}
try {
return JSON.parse(text) as ApiResult<T>;
} catch {
return {
success: false,
reason: response.ok ? "服务器返回了无法解析的数据" : `请求失败:${response.status}`,
};
}
}
type SessionPayload = {
account: unknown | null;
organization: Organization | null;
organizations: AccountOrganizationOption[];
};
function getSessionWorkspaceHref(session: SessionPayload, preferredHref: string): string {
const activeOrganization = session.organization ?? session.organizations.find((organization) => organization.isActive);
return getWorkspaceHref(activeOrganization?.slug, getWorkspacePathFromPathname(preferredHref));
}
async function resolveWorkspaceRedirect(preferredHref: string): Promise<string> {
const sessionResponse = await fetch("/api/auth/session");
const session = await readApiResult<SessionPayload>(sessionResponse);
if (!session.success) {
return preferredHref;
}
return getSessionWorkspaceHref(session, preferredHref);
}
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 readApiResult<{ setupRequired: boolean }>(bootstrapResponse);
const session = await readApiResult<SessionPayload>(sessionResponse);
const organizationsResult = await readApiResult<{ organizations: Organization[] }>(organizationsResponse);
if (!isMounted || !bootstrap.success || !session.success) {
if (isMounted) {
setMessage(!bootstrap.success ? bootstrap.reason : session.reason);
}
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(getSessionWorkspaceHref(session, "/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 readApiResult<{ account: unknown; pendingApproval?: boolean }>(response);
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
if (result.pendingApproval) {
setMessage(result.reason);
return;
}
router.refresh();
router.replace(await resolveWorkspaceRedirect(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
aria-label="申请加入机构"
name="organizationId"
options={[
{ value: "", label: "暂不加入机构" },
...organizations.map((organization) => ({ value: organization.id, label: organization.name })),
]}
/>
</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>
);
}