feat(panel): implement email otp and passkey login interface

This commit is contained in:
Lemon-miaow committed 2026-07-05 16:48:03 +08:00
1 parent 7becb38488
commit 9079a2cd67
13 files changed
+488 -84

No files matched your search

+78
View File
@@ -667,6 +667,84 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
});
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: "[email protected]", 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 });
@@ -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";
+17 -1
View File
@@ -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.",
+3 -1
View File
@@ -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."
}
+17 -1
View File
@@ -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 即可获取一次性绑定码",
+3 -1
View File
@@ -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 注册已被取消。"
}
+30 -1
View File
@@ -111,6 +111,24 @@ export const api = {
bind: (code: string) =>
request<BindResult>("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<any>("POST", "/auth/passkey/login/begin", { email }),
authPasskeyLoginFinish: (email: string, assertion: any) =>
request<any>("POST", "/auth/passkey/login/finish", { email, assertion }),
authPasskeyDiscoverableBegin: () =>
request<any>("POST", "/auth/passkey/login/discoverable/begin", {}),
authPasskeyDiscoverableFinish: (login_id: string, assertion: any) =>
request<any>("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<ApiError>;
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<ApiError>;
switch (err.code) {
// Local-password auth (spec §B1).
case "local_auth_disabled":
+28
View File
@@ -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(/=+$/, "");
}
+1 -34
View File
@@ -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));
}
} finally {
setRegisteringPasskey(false);
abortControllerRef.current = null;
@@ -566,31 +561,3 @@ 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(/=+$/, "");
}
+311 -41
View File
@@ -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<string | null>(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,45 +92,133 @@ 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 (
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
<Card>
<CardContent className="pt-5">
{/* Tab Selector */}
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1 text-muted-foreground select-none mb-4">
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("password");
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md py-1.5 text-xs font-semibold transition-all focus-visible:outline-none",
activeTab === "password"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("tab_password")}
</button>
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("bind");
}}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md py-1.5 text-xs font-semibold transition-all focus-visible:outline-none",
activeTab === "bind"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
)}
>
{t("tab_bind")}
</button>
</div>
{activeTab === "password" ? (
<CardContent className="pt-6">
{activeTab === "password" && (
<div className="space-y-4">
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">{t("username")}</Label>
@@ -153,7 +254,60 @@ export function Login() {
{submitting ? t("signing_in") : t("sign_in")}
</Button>
</form>
) : (
<div className="relative my-2">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-muted" />
</div>
<div className="relative flex justify-center text-[10px] uppercase">
<span className="bg-card px-2 text-muted-foreground font-semibold tracking-wider">
{t("or_divider")}
</span>
</div>
</div>
<div className="space-y-2">
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={handlePasskeyLoginClick}
disabled={submitting}
>
<Fingerprint className="h-4 w-4 text-primary" />
{t("passkey_btn")}
</Button>
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => {
setError(null);
setActiveTab("email");
}}
disabled={submitting}
>
<Mail className="h-4 w-4 text-muted-foreground" />
{t("tab_email_btn")}
</Button>
<Button
type="button"
variant="outline"
className="w-full justify-center gap-2 font-medium"
onClick={() => {
setError(null);
setActiveTab("bind");
}}
disabled={submitting}
>
<KeyRound className="h-4 w-4 text-muted-foreground" />
{t("tab_bind_btn")}
</Button>
</div>
</div>
)}
{activeTab === "bind" && (
<form onSubmit={handleBindSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bindCode">{t("bind_code")}</Label>
@@ -191,6 +345,122 @@ export function Login() {
</>
)}
</Button>
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("password");
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_password")}</span>
</button>
</div>
</form>
)}
{activeTab === "email" && (
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">{t("email_address")}</Label>
<div className="flex gap-2">
<Input
id="email"
type="email"
placeholder={t("email_placeholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
disabled={submitting || otpSent}
aria-invalid={error ? true : undefined}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSendOtp}
disabled={submitting || !email.trim() || countdown > 0}
className="shrink-0 font-normal"
>
{submitting && !otpSent ? (
<>
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
{t("sending_otp")}
</>
) : countdown > 0 ? (
`${countdown}${t("resend_in")}`
) : (
t("send_otp")
)}
</Button>
</div>
{otpSent && (
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1 leading-normal">
{t("otp_sent")}
</p>
)}
</div>
{otpSent && (
<div className="space-y-2">
<Label htmlFor="otpCode">{t("otp_code")}</Label>
<Input
id="otpCode"
placeholder={t("otp_placeholder")}
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
autoComplete="one-time-code"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
autoFocus
disabled={submitting}
aria-invalid={error ? true : undefined}
/>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
{otpSent && (
<Button
type="submit"
className="w-full"
disabled={submitting || !otpCode.trim()}
>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("signing_in")}
</>
) : (
<>
<Mail className="mr-2 h-4 w-4" />
{t("otp_btn")}
</>
)}
</Button>
)}
<div className="mt-4 text-center">
<button
type="button"
onClick={() => {
setError(null);
setActiveTab("password");
}}
className="text-xs text-muted-foreground hover:text-primary transition-colors inline-flex items-center gap-1 font-medium"
>
<span>←</span>
<span>{t("back_to_password")}</span>
</button>
</div>
</form>
)}
</CardContent>
-1
View File
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import {
Upload,
Loader2,
AlertCircle,
FileText,
X,
ChevronDown,
-1
View File
@@ -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";
@@ -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";