feat(panel): implement email otp and passkey login interface
This commit is contained in:
13 files changed
+520
-116
No files matched your search
@@ -667,6 +667,84 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
return true;
|
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":
|
case "POST auth/logout":
|
||||||
clearSessionCookie(ctx.res);
|
clearSessionCookie(ctx.res);
|
||||||
sendJSON(ctx.res, 200, { ok: true });
|
sendJSON(ctx.res, 200, { ok: true });
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
|
|||||||
@@ -1,12 +1,28 @@
|
|||||||
{
|
{
|
||||||
"login_title": "Sign in to Felis",
|
"login_title": "Sign in to Felis",
|
||||||
"login_subtitle": "Operator console",
|
"login_subtitle": "Operator console",
|
||||||
"username": "Username",
|
"username": "Username or Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"signing_in": "Signing in…",
|
"signing_in": "Signing in…",
|
||||||
"tab_password": "Password",
|
"tab_password": "Password",
|
||||||
"tab_bind": "Bind Code",
|
"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": "Bind Code",
|
||||||
"bind_code_placeholder": "e.g., ABCD2345",
|
"bind_code_placeholder": "e.g., ABCD2345",
|
||||||
"bind_hint": "Type /login in-game to generate a one-time bind code.",
|
"bind_hint": "Type /login in-game to generate a one-time bind code.",
|
||||||
|
|||||||
@@ -25,5 +25,7 @@
|
|||||||
"passkey_challenge_invalid": "Passkey challenge is invalid or expired, please try again.",
|
"passkey_challenge_invalid": "Passkey challenge is invalid or expired, please try again.",
|
||||||
"invalid_attestation": "Could not verify this Passkey, 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_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."
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,28 @@
|
|||||||
{
|
{
|
||||||
"login_title": "登录 Felis",
|
"login_title": "登录 Felis",
|
||||||
"login_subtitle": "运维控制台",
|
"login_subtitle": "运维控制台",
|
||||||
"username": "用户名",
|
"username": "用户名或邮箱",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
"sign_in": "登录",
|
"sign_in": "登录",
|
||||||
"signing_in": "登录中…",
|
"signing_in": "登录中…",
|
||||||
"tab_password": "账号密码",
|
"tab_password": "账号密码",
|
||||||
"tab_bind": "游戏绑定码",
|
"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": "绑定码",
|
||||||
"bind_code_placeholder": "例如:ABCD2345",
|
"bind_code_placeholder": "例如:ABCD2345",
|
||||||
"bind_hint": "在游戏内输入 /login 即可获取一次性绑定码",
|
"bind_hint": "在游戏内输入 /login 即可获取一次性绑定码",
|
||||||
|
|||||||
@@ -25,5 +25,7 @@
|
|||||||
"passkey_challenge_invalid": "验证挑战无效或已过期,请重试。",
|
"passkey_challenge_invalid": "验证挑战无效或已过期,请重试。",
|
||||||
"invalid_attestation": "无法验证此 Passkey,请重试。",
|
"invalid_attestation": "无法验证此 Passkey,请重试。",
|
||||||
"passkey_already_bound": "此 Passkey 已被其他账户绑定。",
|
"passkey_already_bound": "此 Passkey 已被其他账户绑定。",
|
||||||
"passkey_unavailable": "Passkey 功能当前不可用。"
|
"passkey_unavailable": "Passkey 功能当前不可用。",
|
||||||
|
"passkey_not_allowed": "操作已被用户或浏览器取消。",
|
||||||
|
"passkey_aborted": "Passkey 注册已被取消。"
|
||||||
}
|
}
|
||||||
+30
-1
@@ -111,6 +111,24 @@ export const api = {
|
|||||||
bind: (code: string) =>
|
bind: (code: string) =>
|
||||||
request<BindResult>("POST", "/auth/bind", { code }),
|
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
|
// changePassword is callable during the first-login lockdown (the route is
|
||||||
// AllowDuringPasswordChange): the server re-verifies current_password, rejects an
|
// AllowDuringPasswordChange): the server re-verifies current_password, rejects an
|
||||||
// unchanged or weak (8–72 byte) new password, writes the new hash, and revokes
|
// 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. */
|
/** humanizeError turns the stable error code into a user-facing line. */
|
||||||
export function humanizeError(e: unknown): string {
|
export function humanizeError(e: unknown): string {
|
||||||
const err = e as Partial<ApiError>;
|
|
||||||
const t = i18next.getFixedT(null, "errors");
|
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) {
|
switch (err.code) {
|
||||||
// Local-password auth (spec §B1).
|
// Local-password auth (spec §B1).
|
||||||
case "local_auth_disabled":
|
case "local_auth_disabled":
|
||||||
|
|||||||
@@ -5,3 +5,31 @@ import { twMerge } from "tailwind-merge";
|
|||||||
export function cn(...inputs: ClassValue[]): string {
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
return twMerge(clsx(inputs));
|
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(/=+$/, "");
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { PageHeader } from "@/components/PageHeader";
|
|||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { useAsync } from "@/lib/hooks";
|
import { useAsync } from "@/lib/hooks";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
|
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -157,13 +158,7 @@ export function Account() {
|
|||||||
setRegisterDialogOpen(false);
|
setRegisterDialogOpen(false);
|
||||||
setPasskeyNickname("");
|
setPasskeyNickname("");
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === "NotAllowedError") {
|
setPasskeyError(humanizeError(err));
|
||||||
setPasskeyError("操作已被用户或浏览器取消。");
|
|
||||||
} else if (err.name === "AbortError") {
|
|
||||||
setPasskeyError("注册已被取消。");
|
|
||||||
} else {
|
|
||||||
setPasskeyError(humanizeError(err));
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setRegisteringPasskey(false);
|
setRegisteringPasskey(false);
|
||||||
abortControllerRef.current = null;
|
abortControllerRef.current = null;
|
||||||
@@ -566,31 +561,3 @@ function StepBadge({ n }: { n: number }) {
|
|||||||
</span>
|
</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(/=+$/, "");
|
|
||||||
}
|
|
||||||
+342
-72
@@ -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 { 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 { useTranslation } from "react-i18next";
|
||||||
import { AuthLayout } from "@/components/AuthLayout";
|
import { AuthLayout } from "@/components/AuthLayout";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
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
|
// 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
|
// surface — username + password; Passkey/PWA onboarding is Phase B2/C. On success
|
||||||
@@ -24,13 +24,26 @@ export function Login() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useTranslation("auth");
|
const { t } = useTranslation("auth");
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<"password" | "bind">("password");
|
const [activeTab, setActiveTab] = useState<"password" | "bind" | "email">("password");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [bindCode, setBindCode] = 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 [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// 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.
|
// would briefly see a login form before being redirected away.
|
||||||
if (loading) {
|
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 (
|
return (
|
||||||
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
|
<AuthLayout title={t("login_title")} subtitle={t("login_subtitle")}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="pt-5">
|
<CardContent className="pt-6">
|
||||||
{/* Tab Selector */}
|
{activeTab === "password" && (
|
||||||
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1 text-muted-foreground select-none mb-4">
|
<div className="space-y-4">
|
||||||
<button
|
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||||
type="button"
|
<div className="space-y-2">
|
||||||
onClick={() => {
|
<Label htmlFor="username">{t("username")}</Label>
|
||||||
setError(null);
|
<Input
|
||||||
setActiveTab("password");
|
id="username"
|
||||||
}}
|
value={username}
|
||||||
className={cn(
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md py-1.5 text-xs font-semibold transition-all focus-visible:outline-none",
|
autoComplete="username"
|
||||||
activeTab === "password"
|
autoCapitalize="none"
|
||||||
? "bg-background text-foreground shadow-sm"
|
autoCorrect="off"
|
||||||
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
spellCheck={false}
|
||||||
)}
|
autoFocus
|
||||||
>
|
aria-invalid={error ? true : undefined}
|
||||||
{t("tab_password")}
|
/>
|
||||||
</button>
|
</div>
|
||||||
<button
|
<div className="space-y-2">
|
||||||
type="button"
|
<Label htmlFor="password">{t("password")}</Label>
|
||||||
onClick={() => {
|
<Input
|
||||||
setError(null);
|
id="password"
|
||||||
setActiveTab("bind");
|
type="password"
|
||||||
}}
|
value={password}
|
||||||
className={cn(
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md py-1.5 text-xs font-semibold transition-all focus-visible:outline-none",
|
autoComplete="current-password"
|
||||||
activeTab === "bind"
|
aria-invalid={error ? true : undefined}
|
||||||
? "bg-background text-foreground shadow-sm"
|
/>
|
||||||
: "text-muted-foreground hover:bg-background/30 hover:text-foreground",
|
</div>
|
||||||
)}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
>
|
<Button
|
||||||
{t("tab_bind")}
|
type="submit"
|
||||||
</button>
|
className="w-full"
|
||||||
</div>
|
disabled={submitting || !username.trim() || !password}
|
||||||
|
>
|
||||||
|
{submitting ? t("signing_in") : t("sign_in")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
{activeTab === "password" ? (
|
<div className="relative my-2">
|
||||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<div className="space-y-2">
|
<div className="w-full border-t border-muted" />
|
||||||
<Label htmlFor="username">{t("username")}</Label>
|
</div>
|
||||||
<Input
|
<div className="relative flex justify-center text-[10px] uppercase">
|
||||||
id="username"
|
<span className="bg-card px-2 text-muted-foreground font-semibold tracking-wider">
|
||||||
value={username}
|
{t("or_divider")}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
</span>
|
||||||
autoComplete="username"
|
</div>
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect="off"
|
|
||||||
spellCheck={false}
|
|
||||||
autoFocus
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">{t("password")}</Label>
|
<Button
|
||||||
<Input
|
type="button"
|
||||||
id="password"
|
variant="outline"
|
||||||
type="password"
|
className="w-full justify-center gap-2 font-medium"
|
||||||
value={password}
|
onClick={handlePasskeyLoginClick}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
disabled={submitting}
|
||||||
autoComplete="current-password"
|
>
|
||||||
aria-invalid={error ? true : undefined}
|
<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>
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
</div>
|
||||||
<Button
|
)}
|
||||||
type="submit"
|
|
||||||
className="w-full"
|
{activeTab === "bind" && (
|
||||||
disabled={submitting || !username.trim() || !password}
|
|
||||||
>
|
|
||||||
{submitting ? t("signing_in") : t("sign_in")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={handleBindSubmit} className="space-y-4">
|
<form onSubmit={handleBindSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bindCode">{t("bind_code")}</Label>
|
<Label htmlFor="bindCode">{t("bind_code")}</Label>
|
||||||
@@ -191,6 +345,122 @@ export function Login() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</form>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
|
||||||
FileText,
|
FileText,
|
||||||
X,
|
X,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useParams } from "react-router-dom";
|
|||||||
import { Users } from "lucide-react";
|
import { Users } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { BackLink } from "@/components/BackLink";
|
import { BackLink } from "@/components/BackLink";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { PhaseBadge } from "@/components/PhaseBadge";
|
import { PhaseBadge } from "@/components/PhaseBadge";
|
||||||
import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States";
|
import { Loading, ErrorState, NotYours, NotRunning } from "@/components/States";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
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 { ClipboardCheck, CheckCircle2, CircleSlash, ChevronDown, ChevronUp, Check, X, Loader2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { StatCard } from "@/components/StatCard";
|
import { StatCard } from "@/components/StatCard";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { SubmissionStatusBadge } from "@/components/SubmissionStatusBadge";
|
import { SubmissionStatusBadge } from "@/components/SubmissionStatusBadge";
|
||||||
|
|||||||
Reference in new issue
Block a user