feat(web-auth): implement email code login pages
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"shadcn": "^4.1.2",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
|
||||
+24
-8
@@ -1,6 +1,13 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { EmailLoginPage } from "@/pages/email-login-page";
|
||||
import { TodoShellPage } from "@/pages/todo-shell-page";
|
||||
import type { EmailLoginResult } from "@/services/auth-api";
|
||||
|
||||
function App() {
|
||||
const [session, setSession] = useState<EmailLoginResult | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f6f8f7] text-[#122117]">
|
||||
<header className="border-b border-[#d7e2db] bg-white/90 backdrop-blur">
|
||||
@@ -9,16 +16,25 @@ function App() {
|
||||
<div className="h-8 w-8 rounded-lg bg-[#0a7a5a]" />
|
||||
<span className="text-lg font-semibold tracking-tight">TodoList</span>
|
||||
</div>
|
||||
<Button className="rounded-md bg-[#0a7a5a] text-white hover:bg-[#0a7a5a]/90">登录</Button>
|
||||
<span className="text-sm text-[#3a5a4a]">{session ? session.user.email : "未登录"}</span>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-8">
|
||||
<section className="rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold">Web 壳已就绪</h1>
|
||||
<p className="mt-2 text-sm text-[#3a5a4a]">
|
||||
下一步将接入邮箱验证码登录、OAuth 回调和会话恢复。
|
||||
</p>
|
||||
</section>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login/email"
|
||||
element={
|
||||
<EmailLoginPage
|
||||
onLoginSuccess={(payload) => {
|
||||
setSession(payload);
|
||||
navigate("/");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<TodoShellPage session={session} />} />
|
||||
<Route path="*" element={<Navigate to="/login/email" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { loginWithEmailCode, sendEmailCode, type EmailLoginResult } from "@/services/auth-api";
|
||||
|
||||
type EmailLoginPageProps = {
|
||||
onLoginSuccess: (payload: EmailLoginResult) => void;
|
||||
};
|
||||
|
||||
export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const canSendCode = useMemo(() => {
|
||||
return email.trim().length > 0 && !sendingCode && codeCooldown <= 0;
|
||||
}, [codeCooldown, email, sendingCode]);
|
||||
|
||||
const canLogin = useMemo(() => {
|
||||
return email.trim().length > 0 && code.trim().length === 6 && !loggingIn;
|
||||
}, [code, email, loggingIn]);
|
||||
|
||||
async function handleSendCode(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canSendCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSendingCode(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
const result = await sendEmailCode(email.trim());
|
||||
setMessage(`验证码已发送,有效期 ${result.expiresInSeconds} 秒`);
|
||||
|
||||
let remain = 60;
|
||||
setCodeCooldown(remain);
|
||||
const timer = window.setInterval(() => {
|
||||
remain -= 1;
|
||||
setCodeCooldown(remain);
|
||||
if (remain <= 0) {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "发送验证码失败");
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(event: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!canLogin) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoggingIn(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
const result = await loginWithEmailCode(email.trim(), code.trim());
|
||||
onLoginSuccess(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败");
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold text-[#122117]">邮箱验证码登录</h1>
|
||||
<p className="mt-2 text-sm text-[#3a5a4a]">输入邮箱后获取验证码,再完成登录。</p>
|
||||
|
||||
<form className="mt-6 space-y-3" onSubmit={handleSendCode}>
|
||||
<label className="block text-sm font-medium text-[#244236]" htmlFor="email">
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="w-full rounded-md border border-[#bfd0c7] px-3 py-2 text-sm outline-none focus:border-[#0a7a5a]"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={!canSendCode} className="w-full">
|
||||
{sendingCode
|
||||
? "发送中..."
|
||||
: codeCooldown > 0
|
||||
? `${codeCooldown}s 后可重发`
|
||||
: "发送验证码"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form className="mt-4 space-y-3" onSubmit={handleLogin}>
|
||||
<label className="block text-sm font-medium text-[#244236]" htmlFor="code">
|
||||
验证码
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
className="w-full rounded-md border border-[#bfd0c7] px-3 py-2 text-sm outline-none focus:border-[#0a7a5a]"
|
||||
placeholder="6位数字验证码"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!canLogin}
|
||||
className="w-full bg-[#0a7a5a] text-white hover:bg-[#0a7a5a]/90"
|
||||
>
|
||||
{loggingIn ? "登录中..." : "立即登录"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{message ? <p className="mt-4 text-sm text-[#0a7a5a]">{message}</p> : null}
|
||||
{error ? <p className="mt-2 text-sm text-[#b42318]">{error}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { EmailLoginResult } from "@/services/auth-api";
|
||||
|
||||
type TodoShellPageProps = {
|
||||
session: EmailLoginResult | null;
|
||||
};
|
||||
|
||||
export function TodoShellPage({ session }: TodoShellPageProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm">
|
||||
<h1 className="text-2xl font-semibold text-[#122117]">TodoList 工作台</h1>
|
||||
<p className="mt-2 text-sm text-[#3a5a4a]">
|
||||
{session
|
||||
? `当前登录用户:${session.user.email}`
|
||||
: "当前未建立会话,后续提交会补齐会话恢复和路由守卫。"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
export type SendEmailCodeResult = {
|
||||
success: boolean;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
export type EmailLoginResult = {
|
||||
accessToken: string;
|
||||
tokenType: "Bearer";
|
||||
expiresInSeconds: number;
|
||||
refreshToken: string;
|
||||
refreshExpiresInSeconds: number;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_API_BASE_URL = "http://localhost:3000";
|
||||
|
||||
function resolveApiBaseUrl(): string {
|
||||
const envBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
if (!envBaseUrl) {
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
return envBaseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function parseErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { message?: string | string[] };
|
||||
if (Array.isArray(body.message)) {
|
||||
return body.message.join(";");
|
||||
}
|
||||
if (typeof body.message === "string" && body.message.trim()) {
|
||||
return body.message;
|
||||
}
|
||||
} catch {
|
||||
return `请求失败(${response.status})`;
|
||||
}
|
||||
|
||||
return `请求失败(${response.status})`;
|
||||
}
|
||||
|
||||
export async function sendEmailCode(email: string): Promise<SendEmailCodeResult> {
|
||||
const response = await fetch(`${resolveApiBaseUrl()}/auth/email/send-code`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseErrorMessage(response));
|
||||
}
|
||||
|
||||
const body = (await response.json()) as SendEmailCodeResult;
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function loginWithEmailCode(email: string, code: string): Promise<EmailLoginResult> {
|
||||
const response = await fetch(`${resolveApiBaseUrl()}/auth/email/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ email, code })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await parseErrorMessage(response));
|
||||
}
|
||||
|
||||
const body = (await response.json()) as EmailLoginResult;
|
||||
return body;
|
||||
}
|
||||
Reference in New Issue
Block a user