feat(web-auth): add oauth callbacks and session bootstrap

This commit is contained in:
2026-04-05 02:54:50 +08:00
parent fe4f7909e3
commit 4b47d3bda7
5 changed files with 205 additions and 18 deletions
+35 -5
View File
@@ -1,11 +1,24 @@
import { useState } from "react"; import { useState } from "react";
import { Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { EmailLoginPage } from "@/pages/email-login-page"; import { EmailLoginPage } from "@/pages/email-login-page";
import { OAuthCallbackPage } from "@/pages/oauth-callback-page";
import { TodoShellPage } from "@/pages/todo-shell-page"; import { TodoShellPage } from "@/pages/todo-shell-page";
import type { EmailLoginResult } from "@/services/auth-api"; 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() { function App() {
const [session, setSession] = useState<EmailLoginResult | null>(null); const [session, setSession] = useState<WebSession | null>(() => loadSession());
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
@@ -26,14 +39,31 @@ function App() {
element={ element={
<EmailLoginPage <EmailLoginPage
onLoginSuccess={(payload) => { onLoginSuccess={(payload) => {
setSession(payload); const nextSession = toWebSession(payload);
saveSession(nextSession);
setSession(nextSession);
navigate("/"); navigate("/");
}} }}
/> />
} }
/> />
<Route path="/" element={<TodoShellPage session={session} />} /> <Route
<Route path="*" element={<Navigate to="/login/email" replace />} /> path="/auth/callback/:provider"
element={
<OAuthCallbackPage
onBootstrapSession={(nextSession) => {
setSession(nextSession);
}}
/>
}
/>
<Route
path="/"
element={
session ? <TodoShellPage session={session} /> : <Navigate to="/login/email" replace />
}
/>
<Route path="*" element={<Navigate to={session ? "/" : "/login/email"} replace />} />
</Routes> </Routes>
</main> </main>
</div> </div>
+35 -8
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { loginWithEmailCode, sendEmailCode, type EmailLoginResult } from "@/services/auth-api"; import { loginWithEmailCode, sendEmailCode, type EmailLoginResult } from "@/services/auth-api";
@@ -7,6 +7,17 @@ type EmailLoginPageProps = {
onLoginSuccess: (payload: EmailLoginResult) => void; 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) { export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [code, setCode] = useState(""); const [code, setCode] = useState("");
@@ -35,7 +46,7 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
setError(null); setError(null);
setMessage(null); setMessage(null);
const result = await sendEmailCode(email.trim()); const result = await sendEmailCode(email.trim());
setMessage(`验证码已发送,有效期 ${result.expiresInSeconds}`); setMessage(`验证码已发送,有效期 ${result.expiresInSeconds}`);
let remain = 60; let remain = 60;
setCodeCooldown(remain); setCodeCooldown(remain);
@@ -75,7 +86,9 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
return ( return (
<div className="mx-auto w-full max-w-md rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm"> <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> <h1 className="text-2xl font-semibold text-[#122117]"></h1>
<p className="mt-2 text-sm text-[#3a5a4a]"></p> <p className="mt-2 text-sm text-[#3a5a4a]">
使
</p>
<form className="mt-6 space-y-3" onSubmit={handleSendCode}> <form className="mt-6 space-y-3" onSubmit={handleSendCode}>
<label className="block text-sm font-medium text-[#244236]" htmlFor="email"> <label className="block text-sm font-medium text-[#244236]" htmlFor="email">
@@ -90,11 +103,7 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
onChange={(event) => setEmail(event.target.value)} onChange={(event) => setEmail(event.target.value)}
/> />
<Button type="submit" disabled={!canSendCode} className="w-full"> <Button type="submit" disabled={!canSendCode} className="w-full">
{sendingCode {sendingCode ? "发送中..." : codeCooldown > 0 ? `${codeCooldown} 秒后重发` : "发送验证码"}
? "发送中..."
: codeCooldown > 0
? `${codeCooldown}s 后可重发`
: "发送验证码"}
</Button> </Button>
</form> </form>
@@ -121,6 +130,24 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
</Button> </Button>
</form> </form>
<div className="mt-6 grid grid-cols-1 gap-2">
<a href={`${resolveApiBaseUrl()}/auth/oauth/github`}>
<Button type="button" variant="outline" className="w-full">
使 GitHub
</Button>
</a>
<a href={`${resolveApiBaseUrl()}/auth/oauth/qq`}>
<Button type="button" variant="outline" className="w-full">
使 QQ
</Button>
</a>
<a href={`${resolveApiBaseUrl()}/auth/oauth/wechat`}>
<Button type="button" variant="outline" className="w-full">
使
</Button>
</a>
</div>
{message ? <p className="mt-4 text-sm text-[#0a7a5a]">{message}</p> : null} {message ? <p className="mt-4 text-sm text-[#0a7a5a]">{message}</p> : null}
{error ? <p className="mt-2 text-sm text-[#b42318]">{error}</p> : null} {error ? <p className="mt-2 text-sm text-[#b42318]">{error}</p> : null}
</div> </div>
@@ -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 (
<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]">OAuth </h1>
<p className="mt-2 text-sm text-[#3a5a4a]">
{parseResult.ok ? "已收到回调参数,点击继续进入工作台。" : parseResult.reason}
</p>
<Button
className="mt-6 w-full bg-[#0a7a5a] text-white hover:bg-[#0a7a5a]/90"
onClick={handleContinue}
>
{parseResult.ok ? "继续" : "返回邮箱登录"}
</Button>
</div>
);
}
+3 -5
View File
@@ -1,7 +1,7 @@
import type { EmailLoginResult } from "@/services/auth-api"; import type { WebSession } from "@/services/session-storage";
type TodoShellPageProps = { type TodoShellPageProps = {
session: EmailLoginResult | null; session: WebSession | null;
}; };
export function TodoShellPage({ session }: TodoShellPageProps) { export function TodoShellPage({ session }: TodoShellPageProps) {
@@ -9,9 +9,7 @@ export function TodoShellPage({ session }: TodoShellPageProps) {
<div className="rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm"> <div className="rounded-xl border border-[#d7e2db] bg-white p-6 shadow-sm">
<h1 className="text-2xl font-semibold text-[#122117]">TodoList </h1> <h1 className="text-2xl font-semibold text-[#122117]">TodoList </h1>
<p className="mt-2 text-sm text-[#3a5a4a]"> <p className="mt-2 text-sm text-[#3a5a4a]">
{session {session ? `当前登录邮箱:${session.user.email}` : "当前未建立登录会话,请先完成登录。"}
? `当前登录用户:${session.user.email}`
: "当前未建立会话,后续提交会补齐会话恢复和路由守卫。"}
</p> </p>
</div> </div>
); );
+67
View File
@@ -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);
}