From 4b47d3bda723625540f3b1831c3a8761ec4a7447 Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Sun, 5 Apr 2026 02:54:50 +0800 Subject: [PATCH] feat(web-auth): add oauth callbacks and session bootstrap --- apps/web/src/App.tsx | 40 +++++++++++-- apps/web/src/pages/email-login-page.tsx | 43 +++++++++++--- apps/web/src/pages/oauth-callback-page.tsx | 65 +++++++++++++++++++++ apps/web/src/pages/todo-shell-page.tsx | 8 +-- apps/web/src/services/session-storage.ts | 67 ++++++++++++++++++++++ 5 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/pages/oauth-callback-page.tsx create mode 100644 apps/web/src/services/session-storage.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8339add..9765446 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,11 +1,24 @@ -import { useState } from "react"; +import { useState } from "react"; import { Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { EmailLoginPage } from "@/pages/email-login-page"; +import { OAuthCallbackPage } from "@/pages/oauth-callback-page"; import { TodoShellPage } from "@/pages/todo-shell-page"; import type { EmailLoginResult } from "@/services/auth-api"; +import { loadSession, saveSession, type WebSession } from "@/services/session-storage"; + +function toWebSession(payload: EmailLoginResult): WebSession { + return { + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + user: { + id: payload.user.id, + email: payload.user.email + } + }; +} function App() { - const [session, setSession] = useState(null); + const [session, setSession] = useState(() => loadSession()); const navigate = useNavigate(); return ( @@ -26,14 +39,31 @@ function App() { element={ { - setSession(payload); + const nextSession = toWebSession(payload); + saveSession(nextSession); + setSession(nextSession); navigate("/"); }} /> } /> - } /> - } /> + { + setSession(nextSession); + }} + /> + } + /> + : + } + /> + } /> diff --git a/apps/web/src/pages/email-login-page.tsx b/apps/web/src/pages/email-login-page.tsx index 8984cd7..fe17509 100644 --- a/apps/web/src/pages/email-login-page.tsx +++ b/apps/web/src/pages/email-login-page.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +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"; @@ -7,6 +7,17 @@ type EmailLoginPageProps = { onLoginSuccess: (payload: EmailLoginResult) => void; }; +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(/\/+$/, ""); +} + export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) { const [email, setEmail] = useState(""); const [code, setCode] = useState(""); @@ -35,7 +46,7 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) { setError(null); setMessage(null); const result = await sendEmailCode(email.trim()); - setMessage(`验证码已发送,有效期 ${result.expiresInSeconds} 秒`); + setMessage(`验证码已发送,有效期 ${result.expiresInSeconds} 秒。`); let remain = 60; setCodeCooldown(remain); @@ -75,7 +86,9 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) { return (

邮箱验证码登录

-

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

+

+ 输入邮箱后获取验证码,再完成登录。你也可以直接使用第三方账号登录。 +

diff --git a/apps/web/src/pages/oauth-callback-page.tsx b/apps/web/src/pages/oauth-callback-page.tsx new file mode 100644 index 0000000..f4ae5f3 --- /dev/null +++ b/apps/web/src/pages/oauth-callback-page.tsx @@ -0,0 +1,65 @@ +import { useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { saveSession, type WebSession } from "@/services/session-storage"; + +type OAuthCallbackPageProps = { + onBootstrapSession: (session: WebSession) => void; +}; + +export function OAuthCallbackPage({ onBootstrapSession }: OAuthCallbackPageProps) { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const parseResult = useMemo(() => { + const accessToken = searchParams.get("accessToken"); + const refreshToken = searchParams.get("refreshToken"); + const userId = searchParams.get("userId"); + const email = searchParams.get("email"); + + if (!accessToken || !refreshToken || !userId || !email) { + return { + ok: false as const, + reason: "回调参数不完整,暂时无法建立会话。" + }; + } + + return { + ok: true as const, + session: { + accessToken, + refreshToken, + user: { + id: userId, + email + } + } + }; + }, [searchParams]); + + function handleContinue(): void { + if (!parseResult.ok) { + navigate("/login/email", { replace: true }); + return; + } + + saveSession(parseResult.session); + onBootstrapSession(parseResult.session); + navigate("/", { replace: true }); + } + + return ( +
+

OAuth 回调处理中

+

+ {parseResult.ok ? "已收到回调参数,点击继续进入工作台。" : parseResult.reason} +

+ +
+ ); +} diff --git a/apps/web/src/pages/todo-shell-page.tsx b/apps/web/src/pages/todo-shell-page.tsx index 90e9134..737789c 100644 --- a/apps/web/src/pages/todo-shell-page.tsx +++ b/apps/web/src/pages/todo-shell-page.tsx @@ -1,7 +1,7 @@ -import type { EmailLoginResult } from "@/services/auth-api"; +import type { WebSession } from "@/services/session-storage"; type TodoShellPageProps = { - session: EmailLoginResult | null; + session: WebSession | null; }; export function TodoShellPage({ session }: TodoShellPageProps) { @@ -9,9 +9,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) {

TodoList 工作台

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

); diff --git a/apps/web/src/services/session-storage.ts b/apps/web/src/services/session-storage.ts new file mode 100644 index 0000000..99963ba --- /dev/null +++ b/apps/web/src/services/session-storage.ts @@ -0,0 +1,67 @@ +import type { EmailLoginResult } from "@/services/auth-api"; + +const SESSION_STORAGE_KEY = "todolist.web.session"; + +export type WebSession = { + accessToken: string; + refreshToken: string; + user: { + id: string; + email: string; + }; +}; + +function isValidSession(payload: unknown): payload is WebSession { + if (!payload || typeof payload !== "object") { + return false; + } + + const data = payload as { + accessToken?: unknown; + refreshToken?: unknown; + user?: { + id?: unknown; + email?: unknown; + }; + }; + + return ( + typeof data.accessToken === "string" && + typeof data.refreshToken === "string" && + typeof data.user?.id === "string" && + typeof data.user?.email === "string" + ); +} + +export function loadSession(): WebSession | null { + const raw = window.localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as unknown; + if (!isValidSession(parsed)) { + return null; + } + return parsed; + } catch { + return null; + } +} + +export function saveSession(payload: EmailLoginResult | WebSession): void { + const session: WebSession = { + accessToken: payload.accessToken, + refreshToken: payload.refreshToken, + user: { + id: payload.user.id, + email: payload.user.email + } + }; + window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); +} + +export function clearSession(): void { + window.localStorage.removeItem(SESSION_STORAGE_KEY); +}