diff --git a/apps/web/package.json b/apps/web/package.json index cdebd8a..c83804f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 61e4804..8339add 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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(null); + const navigate = useNavigate(); + return (
@@ -9,16 +16,25 @@ function App() {
TodoList
- + {session ? session.user.email : "未登录"}
-
-

Web 壳已就绪

-

- 下一步将接入邮箱验证码登录、OAuth 回调和会话恢复。 -

-
+ + { + setSession(payload); + navigate("/"); + }} + /> + } + /> + } /> + } /> +
); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 10ed13e..85a16d5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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( - + + + ); diff --git a/apps/web/src/pages/email-login-page.tsx b/apps/web/src/pages/email-login-page.tsx new file mode 100644 index 0000000..8984cd7 --- /dev/null +++ b/apps/web/src/pages/email-login-page.tsx @@ -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(null); + const [error, setError] = useState(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): Promise { + 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): Promise { + 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 ( +
+

邮箱验证码登录

+

输入邮箱后获取验证码,再完成登录。

+ +
+ + setEmail(event.target.value)} + /> + +
+ +
+ + setCode(event.target.value)} + /> + +
+ + {message ?

{message}

: null} + {error ?

{error}

: null} +
+ ); +} diff --git a/apps/web/src/pages/todo-shell-page.tsx b/apps/web/src/pages/todo-shell-page.tsx new file mode 100644 index 0000000..90e9134 --- /dev/null +++ b/apps/web/src/pages/todo-shell-page.tsx @@ -0,0 +1,18 @@ +import type { EmailLoginResult } from "@/services/auth-api"; + +type TodoShellPageProps = { + session: EmailLoginResult | null; +}; + +export function TodoShellPage({ session }: TodoShellPageProps) { + return ( +
+

TodoList 工作台

+

+ {session + ? `当前登录用户:${session.user.email}` + : "当前未建立会话,后续提交会补齐会话恢复和路由守卫。"} +

+
+ ); +} diff --git a/apps/web/src/services/auth-api.ts b/apps/web/src/services/auth-api.ts new file mode 100644 index 0000000..29a014a --- /dev/null +++ b/apps/web/src/services/auth-api.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf8728f..dfba0f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,9 @@ importers: react-dom: specifier: ^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: specifier: ^4.1.2 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== } + 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: resolution: { @@ -6989,6 +7015,12 @@ packages: } engines: { node: ">= 18" } + set-cookie-parser@2.7.2: + resolution: + { + integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== + } + setprototypeof@1.2.0: resolution: { @@ -12582,6 +12614,20 @@ snapshots: 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: {} read-cache@1.0.0: @@ -12730,6 +12776,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} shadcn@4.1.2(@types/node@24.12.2)(typescript@5.9.3):