feat(web-auth): implement email code login pages
This commit is contained in:
@@ -17,6 +17,7 @@
|
|||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
"shadcn": "^4.1.2",
|
"shadcn": "^4.1.2",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.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() {
|
function App() {
|
||||||
|
const [session, setSession] = useState<EmailLoginResult | null>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#f6f8f7] text-[#122117]">
|
<div className="min-h-screen bg-[#f6f8f7] text-[#122117]">
|
||||||
<header className="border-b border-[#d7e2db] bg-white/90 backdrop-blur">
|
<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]" />
|
<div className="h-8 w-8 rounded-lg bg-[#0a7a5a]" />
|
||||||
<span className="text-lg font-semibold tracking-tight">TodoList</span>
|
<span className="text-lg font-semibold tracking-tight">TodoList</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto w-full max-w-6xl px-4 py-8">
|
<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">
|
<Routes>
|
||||||
<h1 className="text-2xl font-semibold">Web 壳已就绪</h1>
|
<Route
|
||||||
<p className="mt-2 text-sm text-[#3a5a4a]">
|
path="/login/email"
|
||||||
下一步将接入邮箱验证码登录、OAuth 回调和会话恢复。
|
element={
|
||||||
</p>
|
<EmailLoginPage
|
||||||
</section>
|
onLoginSuccess={(payload) => {
|
||||||
|
setSession(payload);
|
||||||
|
navigate("/");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/" element={<TodoShellPage session={session} />} />
|
||||||
|
<Route path="*" element={<Navigate to="/login/email" replace />} />
|
||||||
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>
|
</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;
|
||||||
|
}
|
||||||
Generated
+48
@@ -158,6 +158,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.2.4
|
specifier: ^19.2.4
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
react-router-dom:
|
||||||
|
specifier: ^7.14.0
|
||||||
|
version: 7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
shadcn:
|
shadcn:
|
||||||
specifier: ^4.1.2
|
specifier: ^4.1.2
|
||||||
version: 4.1.2(@types/node@24.12.2)(typescript@5.9.3)
|
version: 4.1.2(@types/node@24.12.2)(typescript@5.9.3)
|
||||||
@@ -6759,6 +6762,29 @@ packages:
|
|||||||
integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||||
}
|
}
|
||||||
|
|
||||||
|
react-router-dom@7.14.0:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==
|
||||||
|
}
|
||||||
|
engines: { node: ">=20.0.0" }
|
||||||
|
peerDependencies:
|
||||||
|
react: ">=18"
|
||||||
|
react-dom: ">=18"
|
||||||
|
|
||||||
|
react-router@7.14.0:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==
|
||||||
|
}
|
||||||
|
engines: { node: ">=20.0.0" }
|
||||||
|
peerDependencies:
|
||||||
|
react: ">=18"
|
||||||
|
react-dom: ">=18"
|
||||||
|
peerDependenciesMeta:
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
react@19.2.4:
|
react@19.2.4:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -6989,6 +7015,12 @@ packages:
|
|||||||
}
|
}
|
||||||
engines: { node: ">= 18" }
|
engines: { node: ">= 18" }
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2:
|
||||||
|
resolution:
|
||||||
|
{
|
||||||
|
integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==
|
||||||
|
}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution:
|
resolution:
|
||||||
{
|
{
|
||||||
@@ -12582,6 +12614,20 @@ snapshots:
|
|||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
|
|
||||||
|
react-router-dom@7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
react-router: 7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
|
||||||
|
react-router@7.14.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
cookie: 1.1.1
|
||||||
|
react: 19.2.4
|
||||||
|
set-cookie-parser: 2.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
react@19.2.4: {}
|
react@19.2.4: {}
|
||||||
|
|
||||||
read-cache@1.0.0:
|
read-cache@1.0.0:
|
||||||
@@ -12730,6 +12776,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
set-cookie-parser@2.7.2: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
shadcn@4.1.2(@types/node@24.12.2)(typescript@5.9.3):
|
shadcn@4.1.2(@types/node@24.12.2)(typescript@5.9.3):
|
||||||
|
|||||||
Reference in New Issue
Block a user