feat(web): TOTP QR code and recovery codes, passkey management and passkey login
- Account & security: TOTP setup renders the otpauth URL as a QR code (qr-code-styling) with the secret as a fallback, shows the 8 recovery codes once after enabling (copy / download), and offers regenerate and disable (verification code or recovery code) when enabled. - New Passkey block: add (named, @simplewebauthn/browser), rename, delete; hidden outside a secure context. - Login page: "用 Passkey 登录" when a passkey is registered and the browser supports it; the TOTP step can switch to a recovery code. - Account menu links to the settings block instead of its own TOTP dialog, so enabling always shows the recovery codes. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
b0e5d539ed
commit
dd0167fe85
6 files changed
+739
-308
No files matched your search
@@ -1,13 +1,26 @@
|
||||
import {
|
||||
type AuthenticationResponseJSON,
|
||||
browserSupportsWebAuthn,
|
||||
type PublicKeyCredentialCreationOptionsJSON,
|
||||
type PublicKeyCredentialRequestOptionsJSON,
|
||||
type RegistrationResponseJSON,
|
||||
startAuthentication,
|
||||
startRegistration,
|
||||
} from "@simplewebauthn/browser";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
AdminSetupRequest,
|
||||
LoginRequest,
|
||||
LoginResult,
|
||||
PasskeyChallenge,
|
||||
PasskeyInfo,
|
||||
PasskeyList,
|
||||
PasswordChangeRequest,
|
||||
PasswordChangeResult,
|
||||
SessionInfo,
|
||||
TotpAction,
|
||||
TotpActionResult,
|
||||
TotpSetup,
|
||||
TotpStatus,
|
||||
} from "@umt/contracts";
|
||||
import { ApiRequestError, apiData, apiFetch } from "~/lib/api.ts";
|
||||
@@ -69,3 +82,89 @@ export function useTotpAction() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 待确认的 TOTP 密钥与 otpauth URL(没有就由服务端生成;刷新不作废) */
|
||||
export function useTotpSetup(enabled: boolean) {
|
||||
return useQuery({
|
||||
// 不挂在 "auth" 下:开启成功后 invalidate("auth") 不该再去取(已开启会 409);不缓存,每次进入绑定都向服务端要
|
||||
// (服务端对未确认的密钥原样返回,刷新不会换码)
|
||||
queryKey: qk.sub("totp-setup"),
|
||||
queryFn: ({ signal }) => apiData<TotpSetup>("/auth/totp/setup", { signal, noAuthRedirect: true }),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Passkey ──
|
||||
|
||||
/** 浏览器能不能用 Passkey:要安全上下文(HTTPS 或 localhost)且支持 WebAuthn */
|
||||
export function passkeySupported(): boolean {
|
||||
return typeof window !== "undefined" && window.isSecureContext && browserSupportsWebAuthn();
|
||||
}
|
||||
|
||||
/** 用户在系统弹窗里取消 / 超时:不当成错误提示 */
|
||||
export function isPasskeyCancel(err: unknown): boolean {
|
||||
const name = (err as { name?: string; cause?: { name?: string } })?.name;
|
||||
const cause = (err as { cause?: { name?: string } })?.cause?.name;
|
||||
return name === "NotAllowedError" || cause === "NotAllowedError" || name === "AbortError";
|
||||
}
|
||||
|
||||
/** Passkey 登录:取挑战 → 浏览器签名 → 校验;成功后服务端下发会话 cookie */
|
||||
export async function loginWithPasskey(): Promise<LoginResult> {
|
||||
const ch = await authPost<PasskeyChallenge>("/auth/passkeys/login/options", {});
|
||||
const response: AuthenticationResponseJSON = await startAuthentication({
|
||||
optionsJSON: ch.options as unknown as PublicKeyCredentialRequestOptionsJSON,
|
||||
});
|
||||
return authPost<LoginResult>("/auth/passkeys/login/verify", { challengeId: ch.challengeId, response });
|
||||
}
|
||||
|
||||
export function usePasskeys(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: qk.sub("auth", "passkeys"),
|
||||
queryFn: ({ signal }) => apiData<PasskeyList>("/auth/passkeys", { signal }),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegisterPasskey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
const ch = await apiData<PasskeyChallenge>("/auth/passkeys/register/options", { method: "POST", body: {} });
|
||||
const response: RegistrationResponseJSON = await startRegistration({
|
||||
optionsJSON: ch.options as unknown as PublicKeyCredentialCreationOptionsJSON,
|
||||
});
|
||||
return apiData<PasskeyInfo>("/auth/passkeys/register/verify", {
|
||||
method: "POST",
|
||||
body: { challengeId: ch.challengeId, name, response },
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.all("auth") });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRenamePasskey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (v: { id: string; name: string }) =>
|
||||
apiData<PasskeyInfo>(`/auth/passkeys/${v.id}`, { method: "PATCH", body: { name: v.name } }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.sub("auth", "passkeys") });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePasskey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => apiData<unknown>(`/auth/passkeys/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.all("auth") });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { PasswordChangeRequest, type SessionInfo, TotpCode } from "@umt/contracts";
|
||||
import { PasswordChangeRequest, type SessionInfo } from "@umt/contracts";
|
||||
import { KeyRound, LoaderCircle, LogOut, ShieldCheck, UserRound } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useChangePassword, useLogout, useTotpAction, useTotpStatus } from "~/api/auth.ts";
|
||||
import { useChangePassword, useLogout } from "~/api/auth.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -105,156 +106,11 @@ function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenCha
|
||||
);
|
||||
}
|
||||
|
||||
/** 二次验证(TOTP):未开启 → 生成密钥 → 在验证器里添加 → 输入 6 位码确认;已开启 → 当前密码 + 验证码关闭 */
|
||||
function TotpDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const status = useTotpStatus(open);
|
||||
const action = useTotpAction();
|
||||
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enabled = status.data?.enabled === true;
|
||||
|
||||
const reset = () => {
|
||||
setSetup(null);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
};
|
||||
const close = (o: boolean) => {
|
||||
if (action.isPending) return;
|
||||
if (!o) reset();
|
||||
onOpenChange(o);
|
||||
};
|
||||
|
||||
const begin = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const r = await action.mutateAsync({ action: "begin" });
|
||||
if (r.secret && r.otpauthUri) setSetup({ secret: r.secret, uri: r.otpauthUri });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!TotpCode.safeParse(code).success) {
|
||||
setError("验证码为 6 位数字");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
if (enabled) {
|
||||
if (!password) {
|
||||
setError("输入当前密码");
|
||||
return;
|
||||
}
|
||||
await action.mutateAsync({ action: "disable", password, code });
|
||||
toast.success("已关闭二次验证");
|
||||
} else {
|
||||
await action.mutateAsync({ action: "enable", code });
|
||||
toast.success("已开启二次验证");
|
||||
}
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={close}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>二次验证(TOTP)</DialogTitle>
|
||||
<DialogDescription>
|
||||
{status.isLoading
|
||||
? "读取状态…"
|
||||
: enabled
|
||||
? "已开启。关闭需要当前密码和验证码。"
|
||||
: "未开启。开启后登录还要输入验证器里的 6 位验证码。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{!enabled && !setup ? (
|
||||
<div className="grid gap-2">
|
||||
{status.data?.pending ? (
|
||||
<p className="text-meta text-muted-foreground">有一个未确认的密钥;重新生成会作废它。</p>
|
||||
) : null}
|
||||
{error ? <p className="text-meta text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<form id="totp-form" onSubmit={submit} className="grid gap-1" noValidate>
|
||||
{setup ? (
|
||||
<div className="mb-2 grid gap-1.5">
|
||||
<p className="text-meta text-muted-foreground">
|
||||
在验证器里手动输入下面的密钥,或粘贴 otpauth 链接。密钥只显示这一次。
|
||||
</p>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm break-all select-all">
|
||||
{grouped}
|
||||
</code>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-meta break-all text-muted-foreground select-all">
|
||||
{setup.uri}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
{enabled ? (
|
||||
<FormField label="当前密码" htmlFor="totp-pwd">
|
||||
<Input
|
||||
id="totp-pwd"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="验证码" htmlFor="totp-code" error={error ?? undefined}>
|
||||
<Input
|
||||
id="totp-code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</FormField>
|
||||
</form>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => close(false)} disabled={action.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
{!enabled && !setup ? (
|
||||
<Button onClick={begin} disabled={action.isPending || status.isLoading}>
|
||||
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
生成密钥
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
form="totp-form"
|
||||
variant={enabled ? "destructive" : "default"}
|
||||
disabled={action.isPending}
|
||||
>
|
||||
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{enabled ? "关闭二次验证" : "确认开启"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** 右上角账户弹层:当前身份、会话到期、改密、二次验证、登出 */
|
||||
export function AccountMenu({ session }: { session: SessionInfo }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pwdOpen, setPwdOpen] = useState(false);
|
||||
const [totpOpen, setTotpOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const logout = useLogout();
|
||||
const actor = session.actor;
|
||||
const isCookie = !!session.expiresAt;
|
||||
@@ -305,7 +161,8 @@ export function AccountMenu({ session }: { session: SessionInfo }) {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setTotpOpen(true);
|
||||
// 开启时要扫码、保存恢复码,放在设置页的账户与安全里做
|
||||
navigate({ pathname: "/console/settings", hash: "#account/totp" });
|
||||
}}
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2 text-left text-body text-foreground hover:bg-foreground/[0.06]"
|
||||
>
|
||||
@@ -329,7 +186,6 @@ export function AccountMenu({ session }: { session: SessionInfo }) {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ChangePasswordDialog open={pwdOpen} onOpenChange={setPwdOpen} />
|
||||
<TotpDialog open={totpOpen} onOpenChange={setTotpOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { PasskeyInfo } from "@umt/contracts";
|
||||
import { Check, KeyRound, LoaderCircle, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
isPasskeyCancel,
|
||||
passkeySupported,
|
||||
useDeletePasskey,
|
||||
usePasskeys,
|
||||
useRegisterPasskey,
|
||||
useRenamePasskey,
|
||||
} from "~/api/auth.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// Passkey:登记(可以多把,各自命名)、改名、删除。登录页有 Passkey 时显示"用 Passkey 登录"。
|
||||
|
||||
/** 按浏览器平台给个默认名,方便区分 */
|
||||
function defaultName(): string {
|
||||
const ua = typeof navigator === "undefined" ? "" : navigator.userAgent;
|
||||
if (/iPhone|iPad/.test(ua)) return "iPhone / iPad";
|
||||
if (/Mac OS X/.test(ua)) return "Mac";
|
||||
if (/Android/.test(ua)) return "Android";
|
||||
if (/Windows/.test(ua)) return "Windows";
|
||||
return "Passkey";
|
||||
}
|
||||
|
||||
function PasskeyRow({ item }: { item: PasskeyInfo }) {
|
||||
const rename = useRenamePasskey();
|
||||
const del = useDeletePasskey();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [name, setName] = useState(item.name);
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const v = name.trim();
|
||||
if (!v || v === item.name) return setEditing(false);
|
||||
try {
|
||||
await rename.mutateAsync({ id: item.id, name: v });
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="flex min-h-12 items-center gap-3 py-2">
|
||||
<KeyRound className="size-4 shrink-0 text-muted-foreground" />
|
||||
{editing ? (
|
||||
<form onSubmit={save} className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<Input
|
||||
aria-label="Passkey 名称"
|
||||
value={name}
|
||||
maxLength={60}
|
||||
autoFocus
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-8 max-w-64"
|
||||
/>
|
||||
<Button type="submit" size="icon-sm" variant="ghost" aria-label="保存名称" disabled={rename.isPending}>
|
||||
{rename.isPending ? <LoaderCircle className="animate-spin" /> : <Check />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="取消改名"
|
||||
onClick={() => {
|
||||
setName(item.name);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-caption font-medium">{item.name}</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
{formatDateTime(item.createdAt)} 登记 ·{" "}
|
||||
{item.lastUsedAt ? `${formatDateTime(item.lastUsedAt)} 用过` : "还没用过"}
|
||||
{item.backedUp ? " · 已同步到云端钥匙串" : ""}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{editing ? null : (
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button size="icon-sm" variant="ghost" aria-label={`重命名 ${item.name}`} onClick={() => setEditing(true)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size="icon-sm" variant="ghost" aria-label={`删除 ${item.name}`}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title={`删除 Passkey「${item.name}」?`}
|
||||
description="删除后这把钥匙不能再登录控制台;设备上的钥匙要在系统设置里另外删。"
|
||||
confirmLabel="删除"
|
||||
destructive
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await del.mutateAsync(item.id);
|
||||
toast.success("Passkey 已删除");
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function PasskeyBlock({ disabled }: { disabled: boolean }) {
|
||||
const list = usePasskeys(!disabled);
|
||||
const register = useRegisterPasskey();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const supported = passkeySupported();
|
||||
const items = list.data?.items ?? [];
|
||||
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const r = await register.mutateAsync(name.trim() || defaultName());
|
||||
toast.success(`已登记「${r.name}」`);
|
||||
setAdding(false);
|
||||
setName("");
|
||||
} catch (err) {
|
||||
if (isPasskeyCancel(err)) return;
|
||||
toast.error(errorMessage(err, "登记失败"));
|
||||
}
|
||||
};
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
|
||||
else
|
||||
body = (
|
||||
<div className="grid gap-3">
|
||||
{list.isPending ? (
|
||||
<p className="text-caption text-muted-foreground">读取中…</p>
|
||||
) : items.length ? (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{items.map((p) => (
|
||||
<PasskeyRow key={p.id} item={p} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">还没有登记 Passkey。</p>
|
||||
)}
|
||||
{!supported ? (
|
||||
<p className="text-meta text-muted-foreground">这个浏览器用不了 Passkey:需要 HTTPS 或 localhost 访问。</p>
|
||||
) : adding ? (
|
||||
<form onSubmit={add} className="flex max-w-md flex-wrap items-center gap-2">
|
||||
<Input
|
||||
aria-label="新 Passkey 的名称"
|
||||
placeholder={defaultName()}
|
||||
value={name}
|
||||
maxLength={60}
|
||||
autoFocus
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={register.isPending}>
|
||||
{register.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
继续
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setAdding(false)}
|
||||
disabled={register.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus />
|
||||
添加 Passkey
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="account"
|
||||
anchor="passkeys"
|
||||
title="Passkey"
|
||||
description="用指纹、面容或安全钥匙直接登录,不用输密码和验证码。可以登记多把。"
|
||||
>
|
||||
{body}
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { RECOVERY_CODE_COUNT, TotpCode } from "@umt/contracts";
|
||||
import { Copy, Download, LoaderCircle } from "lucide-react";
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTotpAction, useTotpSetup, useTotpStatus } from "~/api/auth.ts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// 二次验证(TOTP):扫码绑定 → 输入验证码开启 → 显示一次性恢复码;已开启时可重新生成恢复码或关闭。
|
||||
|
||||
function QrCode({ value }: { value: string }) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = box.current;
|
||||
if (!el) return;
|
||||
el.replaceChildren();
|
||||
const qr = new QRCodeStyling({
|
||||
width: 176,
|
||||
height: 176,
|
||||
type: "svg",
|
||||
data: value,
|
||||
margin: 0,
|
||||
qrOptions: { errorCorrectionLevel: "M" },
|
||||
// token-allow: 二维码必须深色码点 + 白底,扫码器才认
|
||||
dotsOptions: { type: "rounded", color: "#18181b" },
|
||||
// token-allow: 同上
|
||||
cornersSquareOptions: { type: "extra-rounded", color: "#18181b" },
|
||||
// token-allow: 同上
|
||||
backgroundOptions: { color: "#ffffff" },
|
||||
});
|
||||
qr.append(el);
|
||||
}, [value]);
|
||||
return (
|
||||
// token-allow: 二维码必须白底(深色主题下也一样)
|
||||
<div className="w-fit rounded-xl border border-border bg-white p-3">
|
||||
<div ref={box} role="img" aria-label="TOTP 二维码" className="size-44" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 恢复码展示:只此一次;可复制、下载 */
|
||||
function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) {
|
||||
const text = `UHD Media Tool 恢复码(每个只能用一次)\n\n${codes.join("\n")}\n`;
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codes.join("\n"));
|
||||
toast.success("恢复码已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,手动选中复制");
|
||||
}
|
||||
};
|
||||
const download = () => {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/plain;charset=utf-8" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "umt-recovery-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
return (
|
||||
<div className="grid max-w-md gap-3">
|
||||
<p className="text-caption">
|
||||
验证器丢了就用恢复码登录,每个只能用一次。
|
||||
<span className="text-muted-foreground">它们只显示这一次,存到密码管理器里。</span>
|
||||
</p>
|
||||
<ol className="grid grid-cols-2 gap-x-6 gap-y-1 rounded-lg bg-muted px-4 py-3 font-mono text-sm select-all">
|
||||
{codes.map((c) => (
|
||||
<li key={c}>{c}</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={copy}>
|
||||
<Copy />
|
||||
复制
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={download}>
|
||||
<Download />
|
||||
下载
|
||||
</Button>
|
||||
<Button size="sm" onClick={onDone}>
|
||||
已保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Mode = "idle" | "setup" | "codes" | "disable" | "regenerate";
|
||||
|
||||
export function TotpBlock({ disabled }: { disabled: boolean }) {
|
||||
const status = useTotpStatus(!disabled);
|
||||
const action = useTotpAction();
|
||||
const [mode, setMode] = useState<Mode>("idle");
|
||||
const setup = useTotpSetup(!disabled && mode === "setup");
|
||||
const [codes, setCodes] = useState<string[]>([]);
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enabled = status.data?.enabled === true;
|
||||
const left = status.data?.recoveryCodesRemaining ?? 0;
|
||||
|
||||
const reset = (next: Mode = "idle") => {
|
||||
setMode(next);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const enable = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!TotpCode.safeParse(code).success) return setError("验证码是 6 位数字");
|
||||
setError(null);
|
||||
try {
|
||||
const r = await action.mutateAsync({ action: "enable", code });
|
||||
toast.success("二次验证已开启");
|
||||
setCodes(r.recoveryCodes ?? []);
|
||||
reset(r.recoveryCodes?.length ? "codes" : "idle");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) return setError("输入当前密码");
|
||||
const c = code.trim();
|
||||
if (!/^\d{6}$/.test(c) && c.replace(/[\s-]/g, "").length < 10) return setError("输入 6 位验证码或一个恢复码");
|
||||
setError(null);
|
||||
try {
|
||||
await action.mutateAsync({ action: "disable", password, code: c });
|
||||
toast.success("二次验证已关闭");
|
||||
reset();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const regenerate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!password) return setError("输入当前密码");
|
||||
setError(null);
|
||||
try {
|
||||
const r = await action.mutateAsync({ action: "recovery_codes", password });
|
||||
setCodes(r.recoveryCodes ?? []);
|
||||
reset("codes");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const passwordField = (
|
||||
<FormField label="当前密码" htmlFor="acc-totp-pwd">
|
||||
<Input
|
||||
id="acc-totp-pwd"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
);
|
||||
const cancel = (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => reset()} disabled={action.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
);
|
||||
const spinner = action.isPending ? <LoaderCircle className="animate-spin" /> : null;
|
||||
const grouped = setup.data?.secret.replace(/(.{4})/g, "$1 ").trim();
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (disabled) body = <p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>;
|
||||
else if (mode === "codes") body = <RecoveryCodes codes={codes} onDone={() => reset()} />;
|
||||
else if (mode === "setup")
|
||||
body = setup.isPending ? (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="size-4 animate-spin" />
|
||||
生成密钥…
|
||||
</p>
|
||||
) : setup.isError ? (
|
||||
<div className="grid justify-items-start gap-2">
|
||||
<p className="text-meta text-destructive">{errorMessage(setup.error)}</p>
|
||||
{cancel}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={enable} noValidate className="grid gap-4 sm:grid-cols-[auto_minmax(0,1fr)] sm:items-start">
|
||||
<QrCode value={setup.data.otpauthUri} />
|
||||
<div className="grid max-w-md gap-2">
|
||||
<p className="text-caption">用验证器(1Password、Google Authenticator 等)扫码,再输入它显示的 6 位验证码。</p>
|
||||
<details className="text-meta text-muted-foreground">
|
||||
<summary className="cursor-pointer select-none">扫不了码?手动输入密钥</summary>
|
||||
<code className="mt-1.5 block rounded-lg bg-muted px-2 py-1.5 font-mono text-sm break-all text-foreground select-all">
|
||||
{grouped}
|
||||
</code>
|
||||
</details>
|
||||
<FormField label="验证码" htmlFor="acc-totp-code" error={error ?? undefined}>
|
||||
<Input
|
||||
id="acc-totp-code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||
aria-invalid={!!error}
|
||||
className="max-w-40 font-mono tracking-[0.3em]"
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={action.isPending}>
|
||||
{spinner}
|
||||
确认开启
|
||||
</Button>
|
||||
{cancel}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
else if (mode === "disable")
|
||||
body = (
|
||||
<form onSubmit={disable} noValidate className="grid max-w-md gap-1">
|
||||
{passwordField}
|
||||
<FormField label="验证码或恢复码" htmlFor="acc-totp-code" error={error ?? undefined}>
|
||||
<Input
|
||||
id="acc-totp-code"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={16}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" variant="destructive" disabled={action.isPending}>
|
||||
{spinner}
|
||||
关闭二次验证
|
||||
</Button>
|
||||
{cancel}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
else if (mode === "regenerate")
|
||||
body = (
|
||||
<form onSubmit={regenerate} noValidate className="grid max-w-md gap-1">
|
||||
<p className="mb-1 text-caption text-muted-foreground">旧的恢复码会全部作废。</p>
|
||||
{passwordField}
|
||||
{error ? <p className="text-meta text-destructive">{error}</p> : null}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" disabled={action.isPending}>
|
||||
{spinner}
|
||||
重新生成
|
||||
</Button>
|
||||
{cancel}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
else if (enabled)
|
||||
body = (
|
||||
<div className="grid justify-items-start gap-3">
|
||||
<p className={left <= 2 ? "text-caption text-warning" : "text-caption text-muted-foreground"}>
|
||||
还剩 {left} 个恢复码(共 {RECOVERY_CODE_COUNT} 个)。{left <= 2 ? "快用完了,重新生成一组。" : ""}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => reset("regenerate")}>
|
||||
重新生成恢复码
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => reset("disable")}>
|
||||
关闭二次验证
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
else
|
||||
body = (
|
||||
<div className="grid justify-items-start gap-2">
|
||||
<Button size="sm" onClick={() => reset("setup")} disabled={status.isPending}>
|
||||
开启二次验证
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="account"
|
||||
anchor="totp"
|
||||
title="二次验证(TOTP)"
|
||||
description={
|
||||
enabled
|
||||
? "登录时除了密码还要输入验证器里的 6 位验证码。关闭需要当前密码。"
|
||||
: "开启后登录还要输入验证器里的 6 位验证码,同时生成 8 个一次性恢复码。"
|
||||
}
|
||||
actions={
|
||||
disabled || status.isPending ? null : (
|
||||
<StatusBadge tone={enabled ? "success" : "neutral"}>{enabled ? "已开启" : "未开启"}</StatusBadge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{body}
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,20 @@
|
||||
import { PasswordChangeRequest, TotpCode } from "@umt/contracts";
|
||||
import { PasswordChangeRequest } from "@umt/contracts";
|
||||
import { LoaderCircle, LogOut } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useChangePassword, useLogout, useSession, useTotpAction, useTotpStatus } from "~/api/auth.ts";
|
||||
import { useChangePassword, useLogout, useSession } from "~/api/auth.ts";
|
||||
import { AgentShortcutsHelp } from "~/components/console/agent-launcher.tsx";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { clearAuth, errorMessage, getToken, LOGIN_PATH, setToken } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { PasskeyBlock } from "./account-passkeys.tsx";
|
||||
import { TotpBlock } from "./account-totp.tsx";
|
||||
import { anchorId, SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
// 账户与安全:改密、二次验证(TOTP)、当前会话与本机 Token、快捷键
|
||||
// 账户与安全:改密、二次验证(TOTP,account-totp.tsx)、Passkey(account-passkeys.tsx)、当前会话与本机 Token、快捷键
|
||||
|
||||
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
|
||||
message: "两次输入的新密码不一致",
|
||||
@@ -103,136 +104,6 @@ function PasswordBlock({ disabled }: { disabled: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TotpBlock({ disabled }: { disabled: boolean }) {
|
||||
const status = useTotpStatus(!disabled);
|
||||
const action = useTotpAction();
|
||||
const [setup, setSetup] = useState<{ secret: string; uri: string } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enabled = status.data?.enabled === true;
|
||||
|
||||
const reset = () => {
|
||||
setSetup(null);
|
||||
setCode("");
|
||||
setPassword("");
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const begin = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const r = await action.mutateAsync({ action: "begin" });
|
||||
if (r.secret && r.otpauthUri) setSetup({ secret: r.secret, uri: r.otpauthUri });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!TotpCode.safeParse(code).success) {
|
||||
setError("验证码为 6 位数字");
|
||||
return;
|
||||
}
|
||||
if (enabled && !password) {
|
||||
setError("输入当前密码");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
if (enabled) {
|
||||
await action.mutateAsync({ action: "disable", password, code });
|
||||
toast.success("二次验证已关闭");
|
||||
} else {
|
||||
await action.mutateAsync({ action: "enable", code });
|
||||
toast.success("二次验证已开启");
|
||||
}
|
||||
reset();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
const grouped = setup?.secret.replace(/(.{4})/g, "$1 ").trim();
|
||||
|
||||
return (
|
||||
<SettingsBlock
|
||||
section="account"
|
||||
anchor="totp"
|
||||
title="二次验证(TOTP)"
|
||||
description={enabled ? "关闭需要当前密码和验证码。" : "开启后登录还要输入验证器里的 6 位验证码。"}
|
||||
actions={
|
||||
disabled ? null : status.isPending ? null : (
|
||||
<StatusBadge tone={enabled ? "success" : "neutral"}>{enabled ? "已开启" : "未开启"}</StatusBadge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{disabled ? (
|
||||
<p className="text-caption text-muted-foreground">当前用 API Key 访问,登录会话才能修改。</p>
|
||||
) : !enabled && !setup ? (
|
||||
<div className="grid justify-items-start gap-2">
|
||||
{status.data?.pending ? (
|
||||
<p className="text-meta text-muted-foreground">有一个未确认的密钥,重新生成会作废它。</p>
|
||||
) : null}
|
||||
{error ? <p className="text-meta text-destructive">{error}</p> : null}
|
||||
<Button size="sm" onClick={begin} disabled={action.isPending || status.isPending}>
|
||||
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
生成密钥
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} noValidate className="grid max-w-md gap-1">
|
||||
{setup ? (
|
||||
<div className="mb-2 grid gap-1.5">
|
||||
<p className="text-meta text-muted-foreground">
|
||||
在验证器里手动输入密钥,或粘贴 otpauth 链接。密钥只显示这一次。
|
||||
</p>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-sm break-all select-all">{grouped}</code>
|
||||
<code className="rounded-md bg-muted px-2 py-1.5 font-mono text-meta break-all text-muted-foreground select-all">
|
||||
{setup.uri}
|
||||
</code>
|
||||
</div>
|
||||
) : null}
|
||||
{enabled ? (
|
||||
<FormField label="当前密码" htmlFor="acc-totp-pwd">
|
||||
<Input
|
||||
id="acc-totp-pwd"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
<FormField label="验证码" htmlFor="acc-totp-code" error={error ?? undefined}>
|
||||
<Input
|
||||
id="acc-totp-code"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
|
||||
aria-invalid={!!error}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" variant={enabled ? "destructive" : "default"} disabled={action.isPending}>
|
||||
{action.isPending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{enabled ? "关闭二次验证" : "确认开启"}
|
||||
</Button>
|
||||
{setup ? (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={reset} disabled={action.isPending}>
|
||||
取消
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</SettingsBlock>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionBlock() {
|
||||
const session = useSession();
|
||||
const logout = useLogout();
|
||||
@@ -321,6 +192,7 @@ export function AccountSection() {
|
||||
<div className="grid gap-8">
|
||||
<PasswordBlock disabled={!cookie} />
|
||||
<TotpBlock disabled={!cookie} />
|
||||
<PasskeyBlock disabled={!cookie} />
|
||||
<SessionBlock />
|
||||
{/* 快捷键说明自带标题,只包卡片与锚点 */}
|
||||
<section id={anchorId("account", "shortcuts")} className="pg-slab min-w-0 scroll-mt-24 rounded-xl p-5">
|
||||
|
||||
+119
-24
@@ -1,10 +1,11 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { AdminSetupRequest, LoginRequest, TotpCode } from "@umt/contracts";
|
||||
import { AdminSetupRequest, LoginRequest, RecoveryCode, TotpCode } from "@umt/contracts";
|
||||
import {
|
||||
AlertCircle,
|
||||
Clapperboard,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { login, setupAdmin, useSession } from "~/api/auth.ts";
|
||||
import { isPasskeyCancel, login, loginWithPasskey, passkeySupported, setupAdmin, useSession } from "~/api/auth.ts";
|
||||
import PrismLeak from "~/components/prism-leak.tsx";
|
||||
import { ThemeToggle } from "~/components/theme-toggle.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -41,7 +42,7 @@ const SetupForm = AdminSetupRequest.extend({ confirm: z.string() }).refine((v) =
|
||||
});
|
||||
|
||||
/** 后端错误码 → 文案(不直接透传后端 message,setup token 类除外) */
|
||||
function errorText(err: unknown, mode: Mode): string {
|
||||
function errorText(err: unknown, mode: Mode | "passkey" | "recovery"): string {
|
||||
if (!(err instanceof ApiRequestError)) return "请求失败,稍后重试";
|
||||
switch (err.code) {
|
||||
case "rate_limited":
|
||||
@@ -49,6 +50,8 @@ function errorText(err: unknown, mode: Mode): string {
|
||||
case "upstream_unavailable":
|
||||
return "连不上服务端,确认它在运行";
|
||||
case "unauthorized":
|
||||
if (mode === "passkey") return "Passkey 校验没通过,换一把或用密码登录";
|
||||
if (mode === "recovery") return "恢复码错误或已用过";
|
||||
if (mode === "totp") return "验证码错误或已用过,等下一个验证码再试";
|
||||
if (mode === "setup") return "初始化口令无效,用服务端日志里最新的一条";
|
||||
return "用户名或密码错误";
|
||||
@@ -57,6 +60,9 @@ function errorText(err: unknown, mode: Mode): string {
|
||||
return "输入格式不正确";
|
||||
case "conflict":
|
||||
return "管理员已初始化,直接登录";
|
||||
case "precondition_failed":
|
||||
if (mode === "passkey") return "登录挑战已过期,再点一次";
|
||||
return err.message || "登录失败";
|
||||
default:
|
||||
return err.message || "登录失败";
|
||||
}
|
||||
@@ -93,6 +99,13 @@ export default function LoginPage() {
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [setupToken, setSetupToken] = useState("");
|
||||
const [totp, setTotp] = useState("");
|
||||
/** 二次验证这一步改用恢复码(验证器不在手边) */
|
||||
const [useRecovery, setUseRecovery] = useState(false);
|
||||
const [recovery, setRecovery] = useState("");
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
// 安全上下文 + 支持 WebAuthn 才显示 Passkey 入口(挂载后再判断,避免与首屏渲染不一致)
|
||||
const [canPasskey, setCanPasskey] = useState(false);
|
||||
useEffect(() => setCanPasskey(passkeySupported()), []);
|
||||
const [showPwd, setShowPwd] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
@@ -147,13 +160,26 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = { username, password, totpCode: mode === "totp" ? totp : undefined };
|
||||
const parsed = (mode === "totp" ? LoginRequest.extend({ totpCode: TotpCode }) : LoginRequest).safeParse(body);
|
||||
const recoveryStep = mode === "totp" && useRecovery;
|
||||
const body = {
|
||||
username,
|
||||
password,
|
||||
totpCode: mode === "totp" && !useRecovery ? totp : undefined,
|
||||
recoveryCode: recoveryStep ? recovery.trim() : undefined,
|
||||
};
|
||||
const schema =
|
||||
mode !== "totp"
|
||||
? LoginRequest
|
||||
: useRecovery
|
||||
? LoginRequest.extend({ recoveryCode: RecoveryCode })
|
||||
: LoginRequest.extend({ totpCode: TotpCode });
|
||||
const parsed = schema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
const fe: Record<string, string> = {};
|
||||
for (const i of parsed.error.issues) fe[String(i.path[0])] ??= i.message;
|
||||
if (fe.username) fe.username = "输入用户名";
|
||||
if (fe.password) fe.password = "输入密码";
|
||||
if (fe.recoveryCode) fe.recoveryCode = "恢复码是 10 位字母数字";
|
||||
setFieldErrors(fe);
|
||||
return;
|
||||
}
|
||||
@@ -171,21 +197,38 @@ export default function LoginPage() {
|
||||
setMode("setup");
|
||||
setError("管理员尚未初始化");
|
||||
} else {
|
||||
setError(errorText(err, mode));
|
||||
setError(errorText(err, recoveryStep ? "recovery" : mode));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPasskey = async () => {
|
||||
if (loading || passkeyLoading) return;
|
||||
setError(null);
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
const r = await loginWithPasskey();
|
||||
if (r.status === "ok") await done();
|
||||
} catch (err) {
|
||||
if (!isPasskeyCancel(err)) setError(errorText(err, "passkey"));
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const probing = session.isPending;
|
||||
const disabled = probing || loading;
|
||||
const disabled = probing || loading || passkeyLoading;
|
||||
const showPasskey = mode === "login" && canPasskey && session.data?.passkeyEnabled === true;
|
||||
const title = mode === "setup" ? "初始化管理员" : mode === "totp" ? "二次验证" : "登录控制台";
|
||||
const subtitle =
|
||||
mode === "setup"
|
||||
? "首次启动:设置唯一的管理员账号。"
|
||||
: mode === "totp"
|
||||
? "输入身份验证器里的 6 位验证码。"
|
||||
? useRecovery
|
||||
? "输入一个没用过的恢复码,用过即作废。"
|
||||
: "输入身份验证器里的 6 位验证码。"
|
||||
: "使用管理员账号登录,会话 12 小时有效。";
|
||||
|
||||
return (
|
||||
@@ -244,7 +287,20 @@ export default function LoginPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="mt-5 space-y-3" onSubmit={onSubmit} noValidate>
|
||||
{showPasskey ? (
|
||||
<div className="mt-5 grid gap-3">
|
||||
<Button type="button" size="lg" className="w-full" onClick={onPasskey} disabled={disabled}>
|
||||
{passkeyLoading ? <LoaderCircle className="animate-spin" /> : <Fingerprint />}用 Passkey 登录
|
||||
</Button>
|
||||
<div className="flex items-center gap-3 text-meta text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
或用密码
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className={cn(showPasskey ? "mt-3" : "mt-5", "space-y-3")} onSubmit={onSubmit} noValidate>
|
||||
{mode === "setup" ? (
|
||||
<Field id="setupToken" label="初始化口令" error={fieldErrors.setupToken}>
|
||||
<IconInput
|
||||
@@ -311,7 +367,23 @@ export default function LoginPage() {
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{mode === "totp" ? (
|
||||
{mode === "totp" && useRecovery ? (
|
||||
<Field id="recovery" label="恢复码" error={fieldErrors.recoveryCode}>
|
||||
<IconInput
|
||||
id="recovery"
|
||||
icon={KeyRound}
|
||||
autoComplete="off"
|
||||
maxLength={16}
|
||||
value={recovery}
|
||||
onChange={(e) => setRecovery(e.target.value)}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!fieldErrors.recoveryCode}
|
||||
className="font-mono tracking-widest uppercase"
|
||||
placeholder="XXXXX-XXXXX"
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
{mode === "totp" && !useRecovery ? (
|
||||
<Field id="totp" label="验证码" error={fieldErrors.totpCode}>
|
||||
<IconInput
|
||||
ref={totpRef}
|
||||
@@ -340,24 +412,47 @@ export default function LoginPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" disabled={disabled}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
variant={showPasskey ? "outline" : "default"}
|
||||
className="w-full"
|
||||
disabled={disabled}
|
||||
>
|
||||
{loading || probing ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{mode === "setup" ? "初始化并登录" : mode === "totp" ? "验证" : "登录"}
|
||||
</Button>
|
||||
{mode === "totp" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setTotp("");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
返回修改账号
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setUseRecovery((v) => !v);
|
||||
setTotp("");
|
||||
setRecovery("");
|
||||
setError(null);
|
||||
setFieldErrors({});
|
||||
}}
|
||||
>
|
||||
{useRecovery ? "改用验证码" : "用恢复码"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
onClick={() => {
|
||||
setMode("login");
|
||||
setTotp("");
|
||||
setRecovery("");
|
||||
setUseRecovery(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
返回修改账号
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{session.isError ? (
|
||||
<p className="text-center text-meta text-muted-foreground">
|
||||
|
||||
Reference in new issue
Block a user