feat(web-auth): implement logout with token revoke and session clear

This commit is contained in:
2026-04-05 15:05:51 +08:00
parent 25857abf26
commit 95c10eca77
2 changed files with 66 additions and 5 deletions
+43 -3
View File
@@ -1,10 +1,16 @@
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 { Button } from "@/components/ui/button";
import { EmailLoginPage } from "@/pages/email-login-page"; import { EmailLoginPage } from "@/pages/email-login-page";
import { OAuthCallbackPage } from "@/pages/oauth-callback-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 { revokeRefreshToken, type EmailLoginResult } from "@/services/auth-api";
import { loadSession, saveSession, type WebSession } from "@/services/session-storage"; import {
clearSession,
loadSession,
saveSession,
type WebSession
} from "@/services/session-storage";
function toWebSession(payload: EmailLoginResult): WebSession { function toWebSession(payload: EmailLoginResult): WebSession {
return { return {
@@ -19,8 +25,27 @@ function toWebSession(payload: EmailLoginResult): WebSession {
function App() { function App() {
const [session, setSession] = useState<WebSession | null>(() => loadSession()); const [session, setSession] = useState<WebSession | null>(() => loadSession());
const [loggingOut, setLoggingOut] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
async function handleLogout(): Promise<void> {
if (!session || loggingOut) {
return;
}
try {
setLoggingOut(true);
await revokeRefreshToken(session.refreshToken);
} catch {
// 登出流程以本地会话清理为最终兜底,避免页面卡在登录态。
} finally {
clearSession();
setSession(null);
setLoggingOut(false);
navigate("/login/email", { replace: true });
}
}
return ( return (
<div className="min-h-screen bg-[#f6f8f7] text-[#122117]"> <div className="min-h-screen bg-[#f6f8f7] text-[#122117]">
<header className="border-b border-[#d7e2db] bg-white/90 backdrop-blur"> <header className="border-b border-[#d7e2db] bg-white/90 backdrop-blur">
@@ -29,7 +54,22 @@ function App() {
<div className="h-8 w-8 rounded-lg bg-[#0a7a5a]" /> <div className="h-8 w-8 rounded-lg bg-[#0a7a5a]" />
<span className="text-lg font-semibold tracking-tight">TodoList</span> <span className="text-lg font-semibold tracking-tight">TodoList</span>
</div> </div>
<span className="text-sm text-[#3a5a4a]">{session ? session.user.email : "未登录"}</span> {session ? (
<div className="flex items-center gap-3">
<span className="text-sm text-[#3a5a4a]">{session.user.email}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={handleLogout}
disabled={loggingOut}
>
{loggingOut ? "退出中..." : "退出登录"}
</Button>
</div>
) : (
<span className="text-sm text-[#3a5a4a]"></span>
)}
</div> </div>
</header> </header>
<main className="mx-auto w-full max-w-6xl px-4 py-8"> <main className="mx-auto w-full max-w-6xl px-4 py-8">
+23 -2
View File
@@ -1,4 +1,4 @@
export type SendEmailCodeResult = { export type SendEmailCodeResult = {
success: boolean; success: boolean;
expiresInSeconds: number; expiresInSeconds: number;
}; };
@@ -15,6 +15,10 @@ export type EmailLoginResult = {
}; };
}; };
type RevokeRefreshTokenResult = {
success: boolean;
};
const DEFAULT_API_BASE_URL = "http://localhost:3000"; const DEFAULT_API_BASE_URL = "http://localhost:3000";
function resolveApiBaseUrl(): string { function resolveApiBaseUrl(): string {
@@ -30,7 +34,7 @@ async function parseErrorMessage(response: Response): Promise<string> {
try { try {
const body = (await response.json()) as { message?: string | string[] }; const body = (await response.json()) as { message?: string | string[] };
if (Array.isArray(body.message)) { if (Array.isArray(body.message)) {
return body.message.join(""); return body.message.join("");
} }
if (typeof body.message === "string" && body.message.trim()) { if (typeof body.message === "string" && body.message.trim()) {
return body.message; return body.message;
@@ -75,3 +79,20 @@ export async function loginWithEmailCode(email: string, code: string): Promise<E
const body = (await response.json()) as EmailLoginResult; const body = (await response.json()) as EmailLoginResult;
return body; return body;
} }
export async function revokeRefreshToken(refreshToken: string): Promise<RevokeRefreshTokenResult> {
const response = await fetch(`${resolveApiBaseUrl()}/auth/token/revoke`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ refreshToken })
});
if (!response.ok) {
throw new Error(await parseErrorMessage(response));
}
const body = (await response.json()) as RevokeRefreshTokenResult;
return body;
}