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
+571 -15

No files matched your search

+26 -1
View File
@@ -17,5 +17,30 @@
"link_code": "Link code",
"link_code_placeholder": "ABCD2345",
"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.",
"session_expired": "Your session expired — please sign in again.",
"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_placeholder": "ABCD2345",
"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": "回档功能当前不可用,请稍后再试。",
"session_expired": "会话已过期——请重新登录。",
"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) =>
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");
case "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":
return t("quota_exceeded");
case "already_claimed":
+1
View File
@@ -215,6 +215,7 @@ export interface Identity {
* the untyped fetch().json() boundary, so consumers MUST compare `=== true` — an
* absent field is `undefined` (correctly "no change owed"), never a thrown access. */
must_change_password: boolean;
email_verified?: boolean;
}
/** 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 { CheckCircle2, Link2, LogOut, ShieldCheck, UserRound } from "lucide-react";
import { useState, useRef, useEffect, type FormEvent } from "react";
import { CheckCircle2, Link2, LogOut, ShieldCheck, UserRound, Mail, Fingerprint, Trash2, KeyRound } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -9,6 +9,15 @@ import { Loading, ErrorState } from "@/components/States";
import { api, humanizeError } from "@/lib/api";
import { useAsync } from "@/lib/hooks";
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
// (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() {
const status = useAsync(() => api.linkStatus(), []);
const { refresh } = useTier();
const { identity, refresh } = useTier();
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.
// For a local session that read now 401s → the tier model flips to
// `unauthenticated` and RequireAuth bounces this page to /login, so no explicit
@@ -103,6 +264,195 @@ export function Account() {
</CardContent>
</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>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -223,3 +573,31 @@ function StepBadge({ n }: { n: number }) {
</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(/=+$/, "");
}