feat(panel): implement email OTP verification and passkey registration management
This commit is contained in:
9 files changed
+567
-11
No files matched your search
@@ -175,11 +175,16 @@ func (a *API) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// the verified token, no lookup escapes it.
|
||||
func (a *API) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
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{
|
||||
"user_id": p.UserID,
|
||||
"email": p.Email,
|
||||
"role": p.Role,
|
||||
"is_admin": p.IsAdmin(),
|
||||
"email_verified": emailVerified,
|
||||
// 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
|
||||
// account straight to the change-password card before any other surface.
|
||||
|
||||
+84
-4
@@ -30,6 +30,7 @@ interface MockAccount {
|
||||
email: string;
|
||||
linked: boolean;
|
||||
mustChangePassword: boolean;
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
interface MockServer extends ServerInfo {
|
||||
@@ -51,6 +52,7 @@ interface MockState {
|
||||
access: Record<string, AccessState>;
|
||||
backups: BackupView[];
|
||||
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
|
||||
@@ -167,10 +169,10 @@ function mockBackups(): BackupView[] {
|
||||
function initialState(): MockState {
|
||||
return {
|
||||
accounts: {
|
||||
owner: account("owner", "admin", true, false),
|
||||
user: account("user", "user", false, false),
|
||||
linked: account("linked", "user", true, false),
|
||||
setup: account("setup", "admin", true, true),
|
||||
owner: account("owner", "admin", true, false, false),
|
||||
user: account("user", "user", false, false, false),
|
||||
linked: account("linked", "user", true, false, true),
|
||||
setup: account("setup", "admin", true, true, false),
|
||||
},
|
||||
images: [
|
||||
{ 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(),
|
||||
},
|
||||
],
|
||||
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,
|
||||
linked: boolean,
|
||||
mustChangePassword: boolean,
|
||||
emailVerified: boolean,
|
||||
): MockAccount {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
linked,
|
||||
mustChangePassword,
|
||||
emailVerified,
|
||||
email: `${id}@mock.felis.local`,
|
||||
};
|
||||
}
|
||||
@@ -406,6 +418,7 @@ function identity(accountInfo: MockAccount): Identity {
|
||||
role: accountInfo.role,
|
||||
is_admin: accountInfo.role === "admin",
|
||||
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":
|
||||
await verifyLinkRoute(ctx);
|
||||
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:
|
||||
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;
|
||||
return await handleServerRoute(ctx);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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": "从未"
|
||||
}
|
||||
@@ -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 功能当前不可用。"
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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(/=+$/, "");
|
||||
}
|
||||
Reference in new issue
Block a user