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 { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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