diff --git a/internal/api/handlers_user.go b/internal/api/handlers_user.go index a38bbbe..ea289d4 100644 --- a/internal/api/handlers_user.go +++ b/internal/api/handlers_user.go @@ -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(), + "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. diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index e5866d5..0035d81 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -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; backups: BackupView[]; builds: Build[]; + passkeys: Record; } // 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 { 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); } diff --git a/panel/src/i18n/resources/en-US/account.json b/panel/src/i18n/resources/en-US/account.json index 28fff52..82e9c74 100644 --- a/panel/src/i18n/resources/en-US/account.json +++ b/panel/src/i18n/resources/en-US/account.json @@ -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" } diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index d4730b2..927a7df 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -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." } diff --git a/panel/src/i18n/resources/zh-CN/account.json b/panel/src/i18n/resources/zh-CN/account.json index 08e74b5..2dcc190 100644 --- a/panel/src/i18n/resources/zh-CN/account.json +++ b/panel/src/i18n/resources/zh-CN/account.json @@ -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": "从未" } diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 0ba9885..5121123 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -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 功能当前不可用。" } diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index d1fb08f..fce6d77 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -230,6 +230,24 @@ export const api = { linkVerify: (code: string) => request("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("POST", "/account/passkey/register/begin"), + + passkeyRegisterFinish: (name: string, attestation: any) => + request("POST", "/account/passkey/register/finish", { name, attestation }), + + passkeyList: () => + request<{ credentials: any[] }>("GET", "/account/passkey/credentials"), + + passkeyDelete: (id: string) => + request("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": diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index fdf08da..39e174a 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -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 diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index ee04343..45db13b 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -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(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(null); + const [registerDialogOpen, setRegisterDialogOpen] = useState(false); + const [deletingMap, setDeletingMap] = useState>({}); + + const abortControllerRef = useRef(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() { + + + + {t("email_verification")} + + + + {identity?.email_verified ? ( +
+
+ + {t("email_verified")} +
+

{t("email_desc")}

+
+ + {identity.email} + +
+
+ ) : ( +
    +
  1. + +
    +

    {t("email_step1")}

    +

    {t("email_step1_desc")}

    + {!emailSent ? ( +
    + setEmailInput(e.target.value)} + disabled={emailSending} + required + className="max-w-[18rem]" + /> + +
    + ) : ( +
    + + {t("email_otp_sent")} ({sentEmailAddress}) + +
    + )} +
    +
  2. + {emailSent && ( +
  3. + +
    +

    {t("email_step2")}

    +

    {t("email_step2_desc")}

    +
    + setOtpCodeInput(e.target.value)} + disabled={emailVerifying} + maxLength={6} + required + className="max-w-[12rem] font-mono text-center tracking-[0.2em]" + /> + +
    +
    +
  4. + )} + {emailError &&

    {emailError}

    } +
+ )} +
+
+ + + + + {t("passkeys")} + + { + if (!open) { + cancelRegistration(); + } else { + setRegisterDialogOpen(true); + } + }}> + + + + +
+ + {t("add_passkey")} + + {t("passkeys_desc")} + + +
+
+ + setPasskeyNickname(e.target.value)} + disabled={registeringPasskey} + required + /> +
+ {passkeyError &&

{passkeyError}

} +
+ + + + +
+
+
+
+ +

{t("passkeys_desc")}

+ {passkeys.loading && !passkeys.data ? ( + + ) : passkeys.error ? ( + + ) : !passkeys.data?.credentials || passkeys.data.credentials.length === 0 ? ( +

{t("no_passkeys")}

+ ) : ( +
+ {passkeys.data.credentials.map((cred: any) => ( +
+
+

+ + {cred.name} +

+
+ + {t("created_at")} + {new Date(cred.created_at).toLocaleString()} + + + {t("last_used")} + {cred.last_used_at ? new Date(cred.last_used_at).toLocaleString() : t("never")} + +
+
+ +
+ ))} +
+ )} +
+
+ @@ -223,3 +573,31 @@ function StepBadge({ n }: { n: number }) { ); } + +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(/=+$/, ""); +}