diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 4a938cb..e7b309e 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -667,6 +667,84 @@ async function handlePublic(ctx: RequestContext): Promise { }); return true; } + case "POST auth/passkey/login/discoverable/begin": { + sendJSON(ctx.res, 200, { + publicKey: { + challenge: "c29tZV9kaXNjb3ZlcmFibGVfY2hhbGxlbmdl", + rp: { name: "Felis Dev", id: "dev.felis.localhost" }, + user: { id: "bW9ja191c2VyX2lk", name: "owner@mock.felis.local", displayName: "owner" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + allowCredentials: [], + timeout: 60000, + }, + login_id: "mock_login_id_1234" + }); + return true; + } + case "POST auth/passkey/login/discoverable/finish": { + const body = await readJSON<{ login_id?: string; assertion?: any }>(ctx.req); + if (!body.login_id || !body.assertion) { + sendError(ctx.res, 400, "bad_request", "login_id and assertion are required"); + return true; + } + setSessionCookie(ctx.res, "owner"); + sendJSON(ctx.res, 200, { + user_id: "mock-owner", + role: "owner" + }); + return true; + } + case "POST auth/passkey/login/begin": { + const body = await readJSON<{ email?: string }>(ctx.req); + if (!body.email || !body.email.includes("@")) { + sendError(ctx.res, 400, "bad_request", "email is required"); + return true; + } + sendJSON(ctx.res, 200, { + challenge: "c29tZV9wYXNza2V5X2NoYWxsZW5nZQ", + rp: { name: "Felis Dev", id: "dev.felis.localhost" }, + user: { id: "bW9ja191c2VyX2lk", name: body.email, displayName: "owner" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + allowCredentials: [{ type: "public-key", id: "cGstMQ" }], + timeout: 60000, + }); + return true; + } + case "POST auth/passkey/login/finish": { + const body = await readJSON<{ email?: string; assertion?: any }>(ctx.req); + if (!body.email || !body.assertion) { + sendError(ctx.res, 400, "bad_request", "email and assertion are required"); + return true; + } + setSessionCookie(ctx.res, "owner"); + sendJSON(ctx.res, 200, { + user_id: "mock-owner", + role: "owner" + }); + return true; + } + case "POST auth/email/start": { + const body = await readJSON<{ email?: string }>(ctx.req); + if (!body.email || !body.email.includes("@")) { + sendError(ctx.res, 400, "bad_request", "email is required"); + return true; + } + sendJSON(ctx.res, 202, { sent: true, expires_at: new Date(Date.now() + 600000).toISOString() }); + return true; + } + case "POST auth/email/verify": { + const body = await readJSON<{ email?: string; code?: string }>(ctx.req); + if (!body.email || body.code !== "123456") { + sendError(ctx.res, 400, "invalid_code", "email code is invalid or expired"); + return true; + } + setSessionCookie(ctx.res, "owner"); + sendJSON(ctx.res, 200, { + user_id: "mock-owner", + role: "owner" + }); + return true; + } case "POST auth/logout": clearSessionCookie(ctx.res); sendJSON(ctx.res, 200, { ok: true }); diff --git a/panel/src/components/EditServerDialog.tsx b/panel/src/components/EditServerDialog.tsx index 7f16df2..e125b42 100644 --- a/panel/src/components/EditServerDialog.tsx +++ b/panel/src/components/EditServerDialog.tsx @@ -17,7 +17,6 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api, humanizeError } from "@/lib/api"; diff --git a/panel/src/i18n/resources/en-US/auth.json b/panel/src/i18n/resources/en-US/auth.json index ec22f8b..366bc8a 100644 --- a/panel/src/i18n/resources/en-US/auth.json +++ b/panel/src/i18n/resources/en-US/auth.json @@ -1,12 +1,28 @@ { "login_title": "Sign in to Felis", "login_subtitle": "Operator console", - "username": "Username", + "username": "Username or Email", "password": "Password", "sign_in": "Sign in", "signing_in": "Signing in…", "tab_password": "Password", "tab_bind": "Bind Code", + "tab_email": "Email OTP", + "other_login_methods": "Other sign-in options", + "or_divider": "or", + "tab_email_btn": "Sign in with Email OTP", + "tab_bind_btn": "Sign in with Bind Code", + "back_to_password": "Back to password login", + "email_address": "Email Address", + "email_placeholder": "Enter your registered email", + "otp_code": "Verification Code", + "otp_placeholder": "Enter 6-digit code", + "send_otp": "Send Code", + "sending_otp": "Sending…", + "otp_sent": "Verification code sent to your email.", + "otp_btn": "Verify & Sign In", + "resend_in": "s", + "passkey_btn": "Sign in with Passkey", "bind_code": "Bind Code", "bind_code_placeholder": "e.g., ABCD2345", "bind_hint": "Type /login in-game to generate a one-time bind code.", diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 927a7df..754397e 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -25,5 +25,7 @@ "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." + "passkey_unavailable": "Passkey subsystem is not available right now.", + "passkey_not_allowed": "The operation was cancelled by the user or browser.", + "passkey_aborted": "Passkey registration was aborted." } diff --git a/panel/src/i18n/resources/zh-CN/auth.json b/panel/src/i18n/resources/zh-CN/auth.json index 0e58ef7..d3f70e4 100644 --- a/panel/src/i18n/resources/zh-CN/auth.json +++ b/panel/src/i18n/resources/zh-CN/auth.json @@ -1,12 +1,28 @@ { "login_title": "登录 Felis", "login_subtitle": "运维控制台", - "username": "用户名", + "username": "用户名或邮箱", "password": "密码", "sign_in": "登录", "signing_in": "登录中…", "tab_password": "账号密码", "tab_bind": "游戏绑定码", + "tab_email": "邮箱验证码", + "other_login_methods": "其他登录方式", + "or_divider": "或", + "tab_email_btn": "使用邮箱验证码登录", + "tab_bind_btn": "使用游戏绑定码登录", + "back_to_password": "返回密码登录", + "email_address": "邮箱地址", + "email_placeholder": "请输入绑定的邮箱", + "otp_code": "验证码", + "otp_placeholder": "请输入 6 位验证码", + "send_otp": "发送验证码", + "sending_otp": "发送中…", + "otp_sent": "验证码已发送至您的邮箱,请查收", + "otp_btn": "验证并登录", + "resend_in": "秒后重试", + "passkey_btn": "使用 Passkey 登录", "bind_code": "绑定码", "bind_code_placeholder": "例如:ABCD2345", "bind_hint": "在游戏内输入 /login 即可获取一次性绑定码", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 5121123..fff7f27 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -25,5 +25,7 @@ "passkey_challenge_invalid": "验证挑战无效或已过期,请重试。", "invalid_attestation": "无法验证此 Passkey,请重试。", "passkey_already_bound": "此 Passkey 已被其他账户绑定。", - "passkey_unavailable": "Passkey 功能当前不可用。" + "passkey_unavailable": "Passkey 功能当前不可用。", + "passkey_not_allowed": "操作已被用户或浏览器取消。", + "passkey_aborted": "Passkey 注册已被取消。" } diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 34d0d88..8a39d4a 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -111,6 +111,24 @@ export const api = { bind: (code: string) => request("POST", "/auth/bind", { code }), + authEmailStart: (email: string) => + request<{ sent: boolean; expires_at: string }>("POST", "/auth/email/start", { email }), + + authEmailVerify: (email: string, code: string) => + request<{ user_id: string; role: string }>("POST", "/auth/email/verify", { email, code }), + + authPasskeyLoginBegin: (email: string) => + request("POST", "/auth/passkey/login/begin", { email }), + + authPasskeyLoginFinish: (email: string, assertion: any) => + request("POST", "/auth/passkey/login/finish", { email, assertion }), + + authPasskeyDiscoverableBegin: () => + request("POST", "/auth/passkey/login/discoverable/begin", {}), + + authPasskeyDiscoverableFinish: (login_id: string, assertion: any) => + request("POST", "/auth/passkey/login/discoverable/finish", { login_id, assertion }), + // changePassword is callable during the first-login lockdown (the route is // AllowDuringPasswordChange): the server re-verifies current_password, rejects an // unchanged or weak (8–72 byte) new password, writes the new hash, and revokes @@ -436,8 +454,19 @@ export function buildLogsStreamURL(apiBase: string, id: string): string { /** humanizeError turns the stable error code into a user-facing line. */ export function humanizeError(e: unknown): string { - const err = e as Partial; const t = i18next.getFixedT(null, "errors"); + + if (e && typeof e === "object" && "name" in e) { + const name = (e as any).name; + if (name === "NotAllowedError") { + return t("passkey_not_allowed"); + } + if (name === "AbortError") { + return t("passkey_aborted"); + } + } + + const err = e as Partial; switch (err.code) { // Local-password auth (spec §B1). case "local_auth_disabled": diff --git a/panel/src/lib/utils.ts b/panel/src/lib/utils.ts index 1538941..0c0c0ed 100644 --- a/panel/src/lib/utils.ts +++ b/panel/src/lib/utils.ts @@ -5,3 +5,31 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } + +export 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; +} + +export 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(/=+$/, ""); +} diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index a087163..5f4f188 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -10,6 +10,7 @@ import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; +import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; import { Dialog, DialogContent, @@ -157,13 +158,7 @@ export function Account() { setRegisterDialogOpen(false); setPasskeyNickname(""); } catch (err: any) { - if (err.name === "NotAllowedError") { - setPasskeyError("操作已被用户或浏览器取消。"); - } else if (err.name === "AbortError") { - setPasskeyError("注册已被取消。"); - } else { - setPasskeyError(humanizeError(err)); - } + setPasskeyError(humanizeError(err)); } finally { setRegisteringPasskey(false); abortControllerRef.current = null; @@ -566,31 +561,3 @@ 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(/=+$/, ""); -} diff --git a/panel/src/pages/Login.tsx b/panel/src/pages/Login.tsx index c4285ae..054dba0 100644 --- a/panel/src/pages/Login.tsx +++ b/panel/src/pages/Login.tsx @@ -1,6 +1,6 @@ -import { useState, type FormEvent } from "react"; +import { useState, useEffect, type FormEvent } from "react"; import { Navigate, useNavigate } from "react-router-dom"; -import { Loader2, KeyRound } from "lucide-react"; +import { Loader2, KeyRound, Mail, Fingerprint } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AuthLayout } from "@/components/AuthLayout"; import { Card, CardContent } from "@/components/ui/card"; @@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useTier } from "@/lib/tier"; import { api, humanizeError } from "@/lib/api"; -import { cn } from "@/lib/utils"; +import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; // Login is the local-password sign-in (spec §B1). It is the ONLY local credential // surface — username + password; Passkey/PWA onboarding is Phase B2/C. On success @@ -24,13 +24,26 @@ export function Login() { const navigate = useNavigate(); const { t } = useTranslation("auth"); - const [activeTab, setActiveTab] = useState<"password" | "bind">("password"); + const [activeTab, setActiveTab] = useState<"password" | "bind" | "email">("password"); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [bindCode, setBindCode] = useState(""); + const [email, setEmail] = useState(""); + const [otpCode, setOtpCode] = useState(""); + const [otpSent, setOtpSent] = useState(false); + const [countdown, setCountdown] = useState(0); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); + // Countdown timer for OTP resend + useEffect(() => { + if (countdown <= 0) return; + const timer = setTimeout(() => { + setCountdown(countdown - 1); + }, 1000); + return () => clearTimeout(timer); + }, [countdown]); + // Don't flash the form while the boot /me is still in flight: a signed-in visitor // would briefly see a login form before being redirected away. if (loading) { @@ -79,81 +92,222 @@ export function Login() { } } + async function handleSendOtp() { + if (!email.trim() || submitting || countdown > 0) return; + setSubmitting(true); + setError(null); + try { + await api.authEmailStart(email.trim()); + setOtpSent(true); + setCountdown(60); + } catch (err) { + setError(humanizeError(err)); + } finally { + setSubmitting(false); + } + } + + async function handleEmailSubmit(e: FormEvent) { + e.preventDefault(); + if (!email.trim() || !otpCode.trim() || submitting) return; + setSubmitting(true); + setError(null); + try { + await api.authEmailVerify(email.trim(), otpCode.trim()); + await refresh(); + navigate("/", { replace: true }); + } catch (err) { + setError(humanizeError(err)); + setSubmitting(false); + } + } + + async function handlePasskeyLoginClick() { + if (submitting) return; + setSubmitting(true); + setError(null); + + const identifier = username.trim(); + try { + let assertion: any; + if (!identifier) { + // Discoverable (Usernameless) passkey login + const options = await api.authPasskeyDiscoverableBegin(); + const publicKey: PublicKeyCredentialRequestOptions = { + ...options.publicKey, + challenge: base64urlToBytes(options.publicKey.challenge), + allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({ + ...cred, + id: base64urlToBytes(cred.id), + })), + }; + + const credential = (await navigator.credentials.get({ + publicKey, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error("Failed to get credential"); + } + + const response = credential.response as AuthenticatorAssertionResponse; + assertion = { + id: credential.id, + rawId: bytesToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bytesToBase64url(response.clientDataJSON), + authenticatorData: bytesToBase64url(response.authenticatorData), + signature: bytesToBase64url(response.signature), + userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null, + }, + }; + + await api.authPasskeyDiscoverableFinish(options.login_id, assertion); + } else { + // Username-first (Email-first) passkey login + if (!identifier.includes("@")) { + throw new Error("使用 Passkey 登录请在上方输入框中输入您绑定的邮箱,或留空直接进行免密登录。"); + } + + const options = await api.authPasskeyLoginBegin(identifier); + const publicKey: PublicKeyCredentialRequestOptions = { + ...options, + challenge: base64urlToBytes(options.challenge), + allowCredentials: options.allowCredentials?.map((cred: any) => ({ + ...cred, + id: base64urlToBytes(cred.id), + })), + }; + + const credential = (await navigator.credentials.get({ + publicKey, + })) as PublicKeyCredential; + + if (!credential) { + throw new Error("Failed to get credential"); + } + + const response = credential.response as AuthenticatorAssertionResponse; + assertion = { + id: credential.id, + rawId: bytesToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bytesToBase64url(response.clientDataJSON), + authenticatorData: bytesToBase64url(response.authenticatorData), + signature: bytesToBase64url(response.signature), + userHandle: response.userHandle ? bytesToBase64url(response.userHandle) : null, + }, + }; + + await api.authPasskeyLoginFinish(identifier, assertion); + } + + await refresh(); + navigate("/", { replace: true }); + } catch (err: any) { + setError(humanizeError(err)); + } finally { + setSubmitting(false); + } + } + return ( - - {/* Tab Selector */} -
- - -
+ + {activeTab === "password" && ( +
+
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoFocus + aria-invalid={error ? true : undefined} + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + aria-invalid={error ? true : undefined} + /> +
+ {error &&

{error}

} + +
- {activeTab === "password" ? ( -
-
- - setUsername(e.target.value)} - autoComplete="username" - autoCapitalize="none" - autoCorrect="off" - spellCheck={false} - autoFocus - aria-invalid={error ? true : undefined} - /> +
+
+
+
+
+ + {t("or_divider")} + +
+
- - setPassword(e.target.value)} - autoComplete="current-password" - aria-invalid={error ? true : undefined} - /> + + +
- {error &&

{error}

} - - - ) : ( +
+ )} + + {activeTab === "bind" && (
@@ -191,6 +345,122 @@ export function Login() { )} + +
+ +
+ + )} + + {activeTab === "email" && ( +
+
+ +
+ setEmail(e.target.value)} + autoComplete="email" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + disabled={submitting || otpSent} + aria-invalid={error ? true : undefined} + className="flex-1" + /> + +
+ {otpSent && ( +

+ {t("otp_sent")} +

+ )} +
+ + {otpSent && ( +
+ + setOtpCode(e.target.value)} + autoComplete="one-time-code" + autoCapitalize="none" + autoCorrect="off" + spellCheck={false} + autoFocus + disabled={submitting} + aria-invalid={error ? true : undefined} + /> +
+ )} + + {error &&

{error}

} + + {otpSent && ( + + )} + +
+ +
)} diff --git a/panel/src/pages/MySubmissionsPage.tsx b/panel/src/pages/MySubmissionsPage.tsx index 1c9860a..6a77a52 100644 --- a/panel/src/pages/MySubmissionsPage.tsx +++ b/panel/src/pages/MySubmissionsPage.tsx @@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next"; import { Upload, Loader2, - AlertCircle, FileText, X, ChevronDown, diff --git a/panel/src/pages/ServerPlayers.tsx b/panel/src/pages/ServerPlayers.tsx index 807dbce..31abfe9 100644 --- a/panel/src/pages/ServerPlayers.tsx +++ b/panel/src/pages/ServerPlayers.tsx @@ -2,7 +2,6 @@ import { useParams } from "react-router-dom"; import { Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { BackLink } from "@/components/BackLink"; -import { Button } from "@/components/ui/button"; import { PhaseBadge } from "@/components/PhaseBadge"; import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States"; import { PageHeader } from "@/components/PageHeader"; diff --git a/panel/src/pages/admin/SubmissionsPage.tsx b/panel/src/pages/admin/SubmissionsPage.tsx index 49903e5..6f0bfdf 100644 --- a/panel/src/pages/admin/SubmissionsPage.tsx +++ b/panel/src/pages/admin/SubmissionsPage.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from "react"; import { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; import { StatCard } from "@/components/StatCard"; import { PageHeader } from "@/components/PageHeader"; import { SubmissionStatusBadge } from "@/components/SubmissionStatusBadge";