feat(panel): implement email OTP verification and passkey registration management

This commit is contained in:
Lemon-miaow committed 2026-07-02 18:20:40 +08:00
1 parent e0bc288444
commit 8594622e23
9 files changed
+567 -11

No files matched your search

+5
View File
@@ -175,11 +175,16 @@ func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
// the verified token, no lookup escapes it. // the verified token, no lookup escapes it.
func (a *API) handleMe(w http.ResponseWriter, r *http.Request) { func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context()) p := principalFromContext(r.Context())
emailVerified := false
if u, err := a.Repo.UserByID(r.Context(), p.UserID); err == nil {
emailVerified = u.EmailVerified
}
writeJSON(w, http.StatusOK, map[string]any{ writeJSON(w, http.StatusOK, map[string]any{
"user_id": p.UserID, "user_id": p.UserID,
"email": p.Email, "email": p.Email,
"role": p.Role, "role": p.Role,
"is_admin": p.IsAdmin(), "is_admin": p.IsAdmin(),
"email_verified": emailVerified,
// must_change_password is meaningful only on the local-password path; the JWT // must_change_password is meaningful only on the local-password path; the JWT
// path leaves it false. The panel uses it to route a freshly-provisioned staff // path leaves it false. The panel uses it to route a freshly-provisioned staff
// account straight to the change-password card before any other surface. // account straight to the change-password card before any other surface.
+84 -4
View File
@@ -30,6 +30,7 @@ interface MockAccount {
email: string; email: string;
linked: boolean; linked: boolean;
mustChangePassword: boolean; mustChangePassword: boolean;
emailVerified: boolean;
} }
interface MockServer extends ServerInfo { interface MockServer extends ServerInfo {
@@ -51,6 +52,7 @@ interface MockState {
access: Record<string, AccessState>; access: Record<string, AccessState>;
backups: BackupView[]; backups: BackupView[];
builds: Build[]; builds: Build[];
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
} }
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock // PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
@@ -167,10 +169,10 @@ function mockBackups(): BackupView[] {
function initialState(): MockState { function initialState(): MockState {
return { return {
accounts: { accounts: {
owner: account("owner", "admin", true, false), owner: account("owner", "admin", true, false, false),
user: account("user", "user", false, false), user: account("user", "user", false, false, false),
linked: account("linked", "user", true, false), linked: account("linked", "user", true, false, true),
setup: account("setup", "admin", true, true), setup: account("setup", "admin", true, true, false),
}, },
images: [ images: [
{ image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" }, { image_ref: "registry.felis.svc:5000/paper-1.21:demo", enabled: true, source: "demo" },
@@ -257,6 +259,14 @@ function initialState(): MockState {
finished_at: new Date(Date.now() - 1700000).toISOString(), finished_at: new Date(Date.now() - 1700000).toISOString(),
}, },
], ],
passkeys: {
owner: [
{ id: "pk-1", name: "YubiKey 5C", created_at: new Date(Date.now() - 30 * DAY_MS).toISOString() },
],
linked: [],
user: [],
setup: [],
},
}; };
} }
@@ -313,12 +323,14 @@ function account(
role: Role, role: Role,
linked: boolean, linked: boolean,
mustChangePassword: boolean, mustChangePassword: boolean,
emailVerified: boolean,
): MockAccount { ): MockAccount {
return { return {
id, id,
role, role,
linked, linked,
mustChangePassword, mustChangePassword,
emailVerified,
email: `${id}@mock.felis.local`, email: `${id}@mock.felis.local`,
}; };
} }
@@ -406,6 +418,7 @@ function identity(accountInfo: MockAccount): Identity {
role: accountInfo.role, role: accountInfo.role,
is_admin: accountInfo.role === "admin", is_admin: accountInfo.role === "admin",
must_change_password: accountInfo.mustChangePassword, must_change_password: accountInfo.mustChangePassword,
email_verified: accountInfo.emailVerified,
}; };
} }
@@ -568,7 +581,74 @@ async function handleSession(ctx: SessionContext): Promise<boolean> {
case "POST account/link/verify": case "POST account/link/verify":
await verifyLinkRoute(ctx); await verifyLinkRoute(ctx);
return true; return true;
case "POST account/email/start": {
const body = await readJSON<{ email?: string }>(ctx.req);
if (!body.email || !body.email.includes("@")) {
sendError(ctx.res, 400, "bad_request", "invalid email");
return true;
}
sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() });
return true;
}
case "POST account/email/verify": {
const body = await readJSON<{ code?: string }>(ctx.req);
if (body.code?.trim() !== "123456") {
sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired");
return true;
}
ctx.account.emailVerified = true;
sendJSON(ctx.res, 200, { verified: true, email: ctx.account.email });
return true;
}
case "POST account/passkey/register/begin": {
sendJSON(ctx.res, 200, {
challenge: "c29tZV9jaGFsbGVuZ2VfZGF0YQ",
rp: { name: "Felis Dev" },
user: {
id: "bW9ja191c2VyX2lk",
name: ctx.account.email,
displayName: ctx.account.email,
},
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
});
return true;
}
case "POST account/passkey/register/finish": {
const body = await readJSON<{ name?: string; attestation?: any }>(ctx.req);
if (!body.name || !body.attestation) {
sendError(ctx.res, 400, "bad_request", "name and attestation are required");
return true;
}
const newCred = {
id: `pk-${Date.now()}`,
name: body.name.trim(),
created_at: new Date().toISOString(),
};
if (!ctx.state.passkeys[ctx.account.id]) {
ctx.state.passkeys[ctx.account.id] = [];
}
ctx.state.passkeys[ctx.account.id].unshift(newCred);
sendJSON(ctx.res, 201, newCred);
return true;
}
case "GET account/passkey/credentials": {
const list = ctx.state.passkeys[ctx.account.id] ?? [];
sendJSON(ctx.res, 200, { credentials: list });
return true;
}
default: default:
if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) {
const id = ctx.parts[5];
if (ctx.state.passkeys[ctx.account.id]) {
const idx = ctx.state.passkeys[ctx.account.id].findIndex((k) => k.id === id);
if (idx >= 0) {
ctx.state.passkeys[ctx.account.id].splice(idx, 1);
}
}
ctx.res.statusCode = 204;
ctx.res.end();
return true;
}
if (await handleImageRoute(ctx)) return true; if (await handleImageRoute(ctx)) return true;
return await handleServerRoute(ctx); return await handleServerRoute(ctx);
} }
+26 -1
View File
@@ -17,5 +17,30 @@
"link_code": "Link code", "link_code": "Link code",
"link_code_placeholder": "ABCD2345", "link_code_placeholder": "ABCD2345",
"verify_btn": "Link", "verify_btn": "Link",
"verifying": "Verifying…" "verifying": "Verifying…",
"email_verification": "Email Verification",
"email_desc": "Verify your email address to secure your account.",
"email_verified": "Verified",
"email_unverified": "Unverified",
"send_code": "Send Code",
"sending_code": "Sending…",
"email_step1": "Enter Email Address",
"email_step1_desc": "Enter the email address you want to bind to request a verification code.",
"email_step2": "Enter Verification Code",
"email_step2_desc": "Enter the 6-digit verification code sent to your email (valid for 10 minutes).",
"email_verify_btn": "Verify",
"email_verifying": "Verifying…",
"email_otp_sent": "Verification code sent.",
"passkeys": "Passkeys",
"passkeys_desc": "Passkeys let you log in securely using your fingerprint, face, or screen lock PIN.",
"no_passkeys": "No registered passkeys.",
"add_passkey": "Add Passkey",
"passkey_name": "Device Nickname",
"passkey_name_placeholder": "e.g., My Phone, YubiKey",
"registering_passkey": "Registering…",
"delete_passkey": "Delete",
"deleting_passkey": "Deleting…",
"created_at": "Registered at: ",
"last_used": "Last used: ",
"never": "Never"
} }
+7 -1
View File
@@ -19,5 +19,11 @@
"restore_unavailable": "Restore isn't available right now — try again later.", "restore_unavailable": "Restore isn't available right now — try again later.",
"session_expired": "Your session expired — please sign in again.", "session_expired": "Your session expired — please sign in again.",
"forbidden": "You are not allowed to do that.", "forbidden": "You are not allowed to do that.",
"generic": "Something went wrong." "generic": "Something went wrong.",
"otp_resend_cooldown": "Verification code requested too frequently, please try again later.",
"otp_locked": "Too many incorrect attempts, please request a new verification code.",
"passkey_challenge_invalid": "Passkey challenge is invalid or expired, please try again.",
"invalid_attestation": "Could not verify this Passkey, please try again.",
"passkey_already_bound": "This Passkey is already bound to another account.",
"passkey_unavailable": "Passkey subsystem is not available right now."
} }
+26 -1
View File
@@ -17,5 +17,30 @@
"link_code": "关联码", "link_code": "关联码",
"link_code_placeholder": "ABCD2345", "link_code_placeholder": "ABCD2345",
"verify_btn": "关联", "verify_btn": "关联",
"verifying": "验证中…" "verifying": "验证中…",
"email_verification": "邮箱验证",
"email_desc": "验证你的电子邮箱以确保账号安全。",
"email_verified": "已验证",
"email_unverified": "未验证",
"send_code": "获取验证码",
"sending_code": "发送中…",
"email_step1": "输入电子邮箱",
"email_step1_desc": "输入你想要绑定的电子邮箱地址以获取验证码。",
"email_step2": "输入验证码",
"email_step2_desc": "输入发送至你邮箱的 6 位数字验证码(10 分钟内有效)。",
"email_verify_btn": "验证",
"email_verifying": "验证中…",
"email_otp_sent": "验证码已发送。",
"passkeys": "Passkey 注册管理",
"passkeys_desc": "Passkey 允许你使用指纹、面容或设备 PIN 码安全登录面板。",
"no_passkeys": "未绑定任何 Passkey。",
"add_passkey": "注册新 Passkey",
"passkey_name": "设备昵称",
"passkey_name_placeholder": "例如:我的手机, YubiKey",
"registering_passkey": "注册中…",
"delete_passkey": "删除",
"deleting_passkey": "删除中…",
"created_at": "注册时间:",
"last_used": "上次使用:",
"never": "从未"
} }
+7 -1
View File
@@ -19,5 +19,11 @@
"restore_unavailable": "回档功能当前不可用,请稍后再试。", "restore_unavailable": "回档功能当前不可用,请稍后再试。",
"session_expired": "会话已过期——请重新登录。", "session_expired": "会话已过期——请重新登录。",
"forbidden": "你无权执行此操作。", "forbidden": "你无权执行此操作。",
"generic": "出了点问题,请稍后重试。" "generic": "出了点问题,请稍后重试。",
"otp_resend_cooldown": "验证码发送频繁,请稍后再试。",
"otp_locked": "验证码错误次数过多,请重新获取验证码。",
"passkey_challenge_invalid": "验证挑战无效或已过期,请重试。",
"invalid_attestation": "无法验证此 Passkey,请重试。",
"passkey_already_bound": "此 Passkey 已被其他账户绑定。",
"passkey_unavailable": "Passkey 功能当前不可用。"
} }
+30
View File
@@ -230,6 +230,24 @@ export const api = {
linkVerify: (code: string) => linkVerify: (code: string) =>
request<LinkResult>("POST", "/account/link/verify", { code }), request<LinkResult>("POST", "/account/link/verify", { code }),
emailStart: (email: string) =>
request<{ sent: boolean; expires_at: string }>("POST", "/account/email/start", { email }),
emailVerify: (code: string) =>
request<{ verified: boolean; email: string }>("POST", "/account/email/verify", { code }),
passkeyRegisterBegin: () =>
request<any>("POST", "/account/passkey/register/begin"),
passkeyRegisterFinish: (name: string, attestation: any) =>
request<any>("POST", "/account/passkey/register/finish", { name, attestation }),
passkeyList: () =>
request<{ credentials: any[] }>("GET", "/account/passkey/credentials"),
passkeyDelete: (id: string) =>
request<void>("DELETE", `/account/passkey/credentials/${id}`),
}; };
/** /**
@@ -272,6 +290,18 @@ export function humanizeError(e: unknown): string {
return t("invalid_code"); return t("invalid_code");
case "already_linked": case "already_linked":
return t("already_linked"); return t("already_linked");
case "otp_resend_cooldown":
return t("otp_resend_cooldown");
case "otp_locked":
return t("otp_locked");
case "passkey_challenge_invalid":
return t("passkey_challenge_invalid");
case "invalid_attestation":
return t("invalid_attestation");
case "passkey_already_bound":
return t("passkey_already_bound");
case "passkey_unavailable":
return t("passkey_unavailable");
case "quota_exceeded": case "quota_exceeded":
return t("quota_exceeded"); return t("quota_exceeded");
case "already_claimed": case "already_claimed":
+1
View File
@@ -215,6 +215,7 @@ export interface Identity {
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an * the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
* absent field is `undefined` (correctly "no change owed"), never a thrown access. */ * absent field is `undefined` (correctly "no change owed"), never a thrown access. */
must_change_password: boolean; must_change_password: boolean;
email_verified?: boolean;
} }
/** LoginResult mirrors POST /api/v1/auth/login (handlers_auth.go handleLogin). The /** LoginResult mirrors POST /api/v1/auth/login (handlers_auth.go handleLogin). The
+381 -3
View File
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from "react"; import { useState, useRef, useEffect, type FormEvent } from "react";
import { CheckCircle2, Link2, LogOut, ShieldCheck, UserRound } from "lucide-react"; import { CheckCircle2, Link2, LogOut, ShieldCheck, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -9,6 +9,15 @@ import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api"; import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks"; import { useAsync } from "@/lib/hooks";
import { useTier } from "@/lib/tier"; import { useTier } from "@/lib/tier";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
// The Account page is the web half of the §10 link flow. A code is born in-game // The Account page is the web half of the §10 link flow. A code is born in-game
// (online-mode auth proves the UUID) and consumed here (the session proves the // (online-mode auth proves the UUID) and consumed here (the session proves the
@@ -18,9 +27,161 @@ import { useTier } from "@/lib/tier";
export function Account() { export function Account() {
const status = useAsync(() => api.linkStatus(), []); const status = useAsync(() => api.linkStatus(), []);
const { refresh } = useTier(); const { identity, refresh } = useTier();
const { t } = useTranslation("account"); const { t } = useTranslation("account");
// Email verification state
const [emailInput, setEmailInput] = useState("");
const [otpCodeInput, setOtpCodeInput] = useState("");
const [emailSending, setEmailSending] = useState(false);
const [emailVerifying, setEmailVerifying] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const [emailSent, setEmailSent] = useState(false);
const [sentEmailAddress, setSentEmailAddress] = useState("");
const [initializedEmail, setInitializedEmail] = useState(false);
useEffect(() => {
if (identity?.email && !initializedEmail) {
setEmailInput(identity.email);
setInitializedEmail(true);
}
}, [identity, initializedEmail]);
async function sendEmailOtp(e: FormEvent) {
e.preventDefault();
const trimmed = emailInput.trim();
if (!trimmed || emailSending) return;
setEmailSending(true);
setEmailError(null);
try {
await api.emailStart(trimmed);
setEmailSent(true);
setSentEmailAddress(trimmed);
} catch (err) {
setEmailError(humanizeError(err));
} finally {
setEmailSending(false);
}
}
async function verifyEmailOtp(e: FormEvent) {
e.preventDefault();
const trimmedCode = otpCodeInput.trim();
if (!trimmedCode || emailVerifying) return;
setEmailVerifying(true);
setEmailError(null);
try {
await api.emailVerify(trimmedCode);
await refresh();
setEmailSent(false);
setEmailInput("");
setOtpCodeInput("");
} catch (err) {
setEmailError(humanizeError(err));
} finally {
setEmailVerifying(false);
}
}
// Passkeys list
const passkeys = useAsync(() => api.passkeyList(), []);
// Passkey registration state
const [passkeyNickname, setPasskeyNickname] = useState("");
const [registeringPasskey, setRegisteringPasskey] = useState(false);
const [passkeyError, setPasskeyError] = useState<string | null>(null);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
const [deletingMap, setDeletingMap] = useState<Record<string, boolean>>({});
const abortControllerRef = useRef<AbortController | null>(null);
function cancelRegistration() {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setRegisterDialogOpen(false);
setPasskeyNickname("");
setPasskeyError(null);
setRegisteringPasskey(false);
}
async function handleRegisterPasskey(e: FormEvent) {
e.preventDefault();
const name = passkeyNickname.trim();
if (!name || registeringPasskey) return;
setRegisteringPasskey(true);
setPasskeyError(null);
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const options = await api.passkeyRegisterBegin();
const publicKey: PublicKeyCredentialCreationOptions = {
...options,
challenge: base64urlToBytes(options.challenge),
user: {
...options.user,
id: base64urlToBytes(options.user.id),
},
excludeCredentials: options.excludeCredentials?.map((cred: any) => ({
...cred,
id: base64urlToBytes(cred.id),
})),
};
const credential = (await navigator.credentials.create({
publicKey,
signal: controller.signal,
})) as PublicKeyCredential;
if (!credential) {
throw new Error("Failed to create credential");
}
const response = credential.response as AuthenticatorAttestationResponse;
const attestation = {
id: credential.id,
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(response.clientDataJSON),
attestationObject: bytesToBase64url(response.attestationObject),
transports: typeof response.getTransports === "function" ? response.getTransports() : [],
},
};
await api.passkeyRegisterFinish(name, attestation);
await passkeys.reload();
setRegisterDialogOpen(false);
setPasskeyNickname("");
} catch (err: any) {
if (err.name === "NotAllowedError") {
setPasskeyError("操作已被用户或浏览器取消。");
} else if (err.name === "AbortError") {
setPasskeyError("注册已被取消。");
} else {
setPasskeyError(humanizeError(err));
}
} finally {
setRegisteringPasskey(false);
abortControllerRef.current = null;
}
}
async function handleDeletePasskey(id: string) {
if (deletingMap[id]) return;
setDeletingMap((prev) => ({ ...prev, [id]: true }));
try {
await api.passkeyDelete(id);
await passkeys.reload();
} catch (err) {
alert(humanizeError(err));
} finally {
setDeletingMap((prev) => ({ ...prev, [id]: false }));
}
}
// Sign-out ends a local-password session: clear it server-side, then refresh /me. // Sign-out ends a local-password session: clear it server-side, then refresh /me.
// For a local session that read now 401s → the tier model flips to // For a local session that read now 401s → the tier model flips to
// `unauthenticated` and RequireAuth bounces this page to /login, so no explicit // `unauthenticated` and RequireAuth bounces this page to /login, so no explicit
@@ -103,6 +264,195 @@ export function Account() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mail className="h-4 w-4 text-primary" /> {t("email_verification")}
</CardTitle>
</CardHeader>
<CardContent className="text-sm">
{identity?.email_verified ? (
<div className="space-y-3">
<div className="flex items-center gap-2 font-medium text-foreground">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
{t("email_verified")}
</div>
<p className="text-muted-foreground">{t("email_desc")}</p>
<div className="flex items-center gap-2 text-muted-foreground">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">
{identity.email}
</code>
</div>
</div>
) : (
<ol className="space-y-4">
<li className="flex gap-3">
<StepBadge n={1} />
<div className="w-full space-y-2">
<p className="font-medium text-foreground">{t("email_step1")}</p>
<p className="text-muted-foreground">{t("email_step1_desc")}</p>
{!emailSent ? (
<form onSubmit={sendEmailOtp} className="flex gap-2 max-w-md">
<Input
type="email"
placeholder="[email protected]"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
disabled={emailSending}
required
className="max-w-[18rem]"
/>
<Button type="submit" disabled={emailSending || !emailInput}>
{emailSending ? t("sending_code") : t("send_code")}
</Button>
</form>
) : (
<div className="flex items-center gap-2 text-emerald-600 font-medium dark:text-emerald-400">
<CheckCircle2 className="h-4 w-4" />
<span>{t("email_otp_sent")} ({sentEmailAddress})</span>
<Button
variant="link"
size="sm"
onClick={() => setEmailSent(false)}
className="h-auto p-0 font-normal"
>
修改邮箱
</Button>
</div>
)}
</div>
</li>
{emailSent && (
<li className="flex gap-3">
<StepBadge n={2} />
<div className="w-full space-y-2">
<p className="font-medium text-foreground">{t("email_step2")}</p>
<p className="text-muted-foreground">{t("email_step2_desc")}</p>
<form onSubmit={verifyEmailOtp} className="flex gap-2 max-w-md">
<Input
type="text"
placeholder={t("otp_code_placeholder")}
value={otpCodeInput}
onChange={(e) => setOtpCodeInput(e.target.value)}
disabled={emailVerifying}
maxLength={6}
required
className="max-w-[12rem] font-mono text-center tracking-[0.2em]"
/>
<Button type="submit" disabled={emailVerifying || otpCodeInput.trim().length !== 6}>
{emailVerifying ? t("email_verifying") : t("email_verify_btn")}
</Button>
</form>
</div>
</li>
)}
{emailError && <p className="text-sm text-destructive ml-9">{emailError}</p>}
</ol>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-2 text-base">
<Fingerprint className="h-4 w-4 text-primary" /> {t("passkeys")}
</CardTitle>
<Dialog open={registerDialogOpen} onOpenChange={(open) => {
if (!open) {
cancelRegistration();
} else {
setRegisterDialogOpen(true);
}
}}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Fingerprint className="mr-1 h-3.5 w-3.5" />
{t("add_passkey")}
</Button>
</DialogTrigger>
<DialogContent hideClose={registeringPasskey}>
<form onSubmit={handleRegisterPasskey}>
<DialogHeader>
<DialogTitle>{t("add_passkey")}</DialogTitle>
<DialogDescription>
{t("passkeys_desc")}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="pk-name">{t("passkey_name")}</Label>
<Input
id="pk-name"
placeholder={t("passkey_name_placeholder")}
value={passkeyNickname}
onChange={(e) => setPasskeyNickname(e.target.value)}
disabled={registeringPasskey}
required
/>
</div>
{passkeyError && <p className="text-sm text-destructive">{passkeyError}</p>}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={cancelRegistration}
disabled={registeringPasskey}
>
取消
</Button>
<Button type="submit" disabled={registeringPasskey || !passkeyNickname.trim()}>
{registeringPasskey ? t("registering_passkey") : "继续"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardHeader>
<CardContent className="text-sm space-y-4">
<p className="text-muted-foreground">{t("passkeys_desc")}</p>
{passkeys.loading && !passkeys.data ? (
<Loading label="加载 Passkey 列表中..." />
) : passkeys.error ? (
<ErrorState error={passkeys.error} onRetry={passkeys.reload} />
) : !passkeys.data?.credentials || passkeys.data.credentials.length === 0 ? (
<p className="text-xs text-muted-foreground py-2 italic">{t("no_passkeys")}</p>
) : (
<div className="border rounded-md divide-y bg-background/50">
{passkeys.data.credentials.map((cred: any) => (
<div key={cred.id} className="flex items-center justify-between p-3">
<div className="space-y-1">
<p className="font-medium text-foreground flex items-center gap-1.5">
<KeyRound className="h-3.5 w-3.5 text-muted-foreground" />
{cred.name}
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span>
{t("created_at")}
{new Date(cred.created_at).toLocaleString()}
</span>
<span>
{t("last_used")}
{cred.last_used_at ? new Date(cred.last_used_at).toLocaleString() : t("never")}
</span>
</div>
</div>
<Button
size="icon"
variant="ghost"
onClick={() => handleDeletePasskey(cred.id)}
disabled={deletingMap[cred.id]}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@@ -223,3 +573,31 @@ function StepBadge({ n }: { n: number }) {
</span> </span>
); );
} }
function base64urlToBytes(str: string): ArrayBuffer {
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4;
if (pad) {
base64 += "=".repeat(4 - pad);
}
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
function bytesToBase64url(bytes: ArrayBuffer): string {
let binary = "";
const uint8 = new Uint8Array(bytes);
const len = uint8.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(uint8[i]);
}
const base64 = btoa(binary);
return base64
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}