feat(web-auth): add oauth callbacks and session bootstrap
This commit is contained in:
+35
-5
@@ -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<EmailLoginResult | null>(null);
|
||||
const [session, setSession] = useState<WebSession | null>(() => loadSession());
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
@@ -26,14 +39,31 @@ function App() {
|
||||
element={
|
||||
<EmailLoginPage
|
||||
onLoginSuccess={(payload) => {
|
||||
setSession(payload);
|
||||
const nextSession = toWebSession(payload);
|
||||
saveSession(nextSession);
|
||||
setSession(nextSession);
|
||||
navigate("/");
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<TodoShellPage session={session} />} />
|
||||
<Route path="*" element={<Navigate to="/login/email" replace />} />
|
||||
<Route
|
||||
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>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
<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">
|
||||
@@ -90,11 +103,7 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
/>
|
||||
<Button type="submit" disabled={!canSendCode} className="w-full">
|
||||
{sendingCode
|
||||
? "发送中..."
|
||||
: codeCooldown > 0
|
||||
? `${codeCooldown}s 后可重发`
|
||||
: "发送验证码"}
|
||||
{sendingCode ? "发送中..." : codeCooldown > 0 ? `${codeCooldown} 秒后重发` : "发送验证码"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -121,6 +130,24 @@ export function EmailLoginPage({ onLoginSuccess }: EmailLoginPageProps) {
|
||||
</Button>
|
||||
</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}
|
||||
{error ? <p className="mt-2 text-sm text-[#b42318]">{error}</p> : null}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
<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}`
|
||||
: "当前未建立会话,后续提交会补齐会话恢复和路由守卫。"}
|
||||
{session ? `当前登录邮箱:${session.user.email}` : "当前未建立登录会话,请先完成登录。"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user