feat(frontend): implement center auth session flow with route guard

This commit is contained in:
root
2026-05-12 15:09:06 +00:00
parent 6219fbbcfe
commit d718128f89
12 changed files with 269 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import ProtectedRoute from './auth/ProtectedRoute'
import AppLayout from './layouts/AppLayout'
import ChatPage from './pages/ChatPage'
import LoginPage from './pages/LoginPage'
@@ -9,8 +10,22 @@ export default function App() {
<Routes>
<Route path="/" element={<AppLayout />}>
<Route index element={<Navigate to="/workspace" replace />} />
<Route path="workspace" element={<WorkspacePage />} />
<Route path="chat" element={<ChatPage />} />
<Route
path="workspace"
element={
<ProtectedRoute>
<WorkspacePage />
</ProtectedRoute>
}
/>
<Route
path="chat"
element={
<ProtectedRoute>
<ChatPage />
</ProtectedRoute>
}
/>
<Route path="login" element={<LoginPage />} />
</Route>
<Route path="*" element={<Navigate to="/workspace" replace />} />

52
src/auth/AuthContext.tsx Normal file
View File

@@ -0,0 +1,52 @@
import { useMemo, useState } from 'react'
import type { PropsWithChildren } from 'react'
import { clearAuthSession, getAuthSession, isAccessTokenStale, setAuthSession } from '../lib/auth-storage'
import type { AuthSession } from '../lib/auth-storage'
import { loginCenter, logoutCenter, refreshCenter } from '../lib/center-auth-client'
import { AuthContext } from './auth-context'
import type { AuthContextValue } from './auth-context'
export function AuthProvider({ children }: PropsWithChildren) {
const [session, setSession] = useState<AuthSession | null>(getAuthSession())
const value = useMemo<AuthContextValue>(
() => ({
session,
isAuthed: !!session,
login: async (email: string, password: string) => {
const next = await loginCenter({ email, password })
setAuthSession(next)
setSession(next)
},
logout: async () => {
if (session?.refreshToken) {
try {
await logoutCenter(session.refreshToken)
} catch {
// noop
}
}
clearAuthSession()
setSession(null)
},
ensureFreshToken: async () => {
if (!session) return null
if (!isAccessTokenStale(session.accessToken)) return session.accessToken
const refreshed = await refreshCenter(session.refreshToken)
const next: AuthSession = {
...session,
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken,
tokenType: refreshed.tokenType,
}
setAuthSession(next)
setSession(next)
return next.accessToken
},
}),
[session],
)
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@@ -0,0 +1,34 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from './auth-context'
import type { PropsWithChildren } from 'react'
import { useEffect, useState } from 'react'
export default function ProtectedRoute({ children }: PropsWithChildren) {
const { isAuthed, ensureFreshToken, logout } = useAuth()
const [ready, setReady] = useState(false)
useEffect(() => {
let active = true
;(async () => {
if (!isAuthed) {
if (active) setReady(true)
return
}
try {
await ensureFreshToken()
} catch {
await logout()
} finally {
if (active) setReady(true)
}
})()
return () => {
active = false
}
}, [isAuthed, ensureFreshToken, logout])
if (!ready) return null
if (!isAuthed) return <Navigate to="/login" replace />
return <>{children}</>
}

18
src/auth/auth-context.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createContext, useContext } from 'react'
import type { AuthSession } from '../lib/auth-storage'
export type AuthContextValue = {
session: AuthSession | null
isAuthed: boolean
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
ensureFreshToken: () => Promise<string | null>
}
export const AuthContext = createContext<AuthContextValue | null>(null)
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
return ctx
}

View File

@@ -1,6 +1,9 @@
import { Link, Outlet } from 'react-router-dom'
import { useAuth } from '../auth/auth-context'
export default function AppLayout() {
const { isAuthed, session, logout } = useAuth()
return (
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '100vh' }}>
<aside style={{ borderRight: '1px solid #ddd', padding: 16 }}>
@@ -10,6 +13,18 @@ export default function AppLayout() {
<Link to="/chat"></Link>
<Link to="/login"></Link>
</nav>
<div style={{ marginTop: 16, fontSize: 12 }}>
{isAuthed ? (
<>
<div>{session?.user.email}</div>
<button onClick={() => logout()} style={{ marginTop: 6 }}>
</button>
</>
) : (
<span></span>
)}
</div>
</aside>
<main style={{ padding: 16 }}>
<Outlet />

47
src/lib/auth-storage.ts Normal file
View File

@@ -0,0 +1,47 @@
export type AuthSession = {
accessToken: string
refreshToken: string
tokenType: string
user: {
id: string
email: string
}
}
const KEY = 'fabric.auth.session.v1'
export function getAuthSession(): AuthSession | null {
const raw = localStorage.getItem(KEY)
if (!raw) return null
try {
return JSON.parse(raw) as AuthSession
} catch {
return null
}
}
export function setAuthSession(session: AuthSession): void {
localStorage.setItem(KEY, JSON.stringify(session))
}
export function clearAuthSession(): void {
localStorage.removeItem(KEY)
}
function parseJwtExpMs(token: string): number | null {
const payload = token.split('.')[1]
if (!payload) return null
try {
const decoded = JSON.parse(atob(payload)) as { exp?: number }
if (!decoded.exp) return null
return decoded.exp * 1000
} catch {
return null
}
}
export function isAccessTokenStale(token: string, leadMs = 60_000): boolean {
const expMs = parseJwtExpMs(token)
if (!expMs) return true
return Date.now() + leadMs >= expMs
}

View File

@@ -0,0 +1,40 @@
import axios from 'axios'
import { getRuntimeConfig } from './runtime-config'
import type { AuthSession } from './auth-storage'
export type LoginPayload = { email: string; password: string }
type LoginResponse = {
accessToken: string
refreshToken: string
tokenType: string
user: { id: string; email: string }
}
type RefreshResponse = {
accessToken: string
refreshToken: string
tokenType: string
}
function centerClient() {
const cfg = getRuntimeConfig()
return axios.create({
baseURL: cfg.centerApiBase,
timeout: 10000,
})
}
export async function loginCenter(payload: LoginPayload): Promise<AuthSession> {
const res = await centerClient().post<LoginResponse>('/auth/login', payload)
return res.data
}
export async function refreshCenter(refreshToken: string): Promise<RefreshResponse> {
const res = await centerClient().post<RefreshResponse>('/auth/refresh', { refreshToken })
return res.data
}
export async function logoutCenter(refreshToken: string): Promise<void> {
await centerClient().post('/auth/logout', { refreshToken })
}

View File

@@ -1,4 +1,5 @@
export type RuntimeConfig = {
centerApiBase: string
guildApiBase: string
guildSocketBase: string
apiKey: string
@@ -7,6 +8,7 @@ export type RuntimeConfig = {
const KEY = 'fabric.runtime.config.v1'
const defaults: RuntimeConfig = {
centerApiBase: import.meta.env.VITE_CENTER_API_BASE ?? 'http://localhost:7001/api',
guildApiBase: import.meta.env.VITE_GUILD_API_BASE ?? 'http://localhost:7002/api',
guildSocketBase: import.meta.env.VITE_GUILD_SOCKET_BASE ?? 'http://localhost:7002/realtime',
apiKey: import.meta.env.VITE_API_KEY ?? '',

View File

@@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from './auth/AuthContext'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<App />
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)

View File

@@ -1,8 +1,41 @@
import { useState } from 'react'
import type { FormEvent } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../auth/auth-context'
export default function LoginPage() {
const navigate = useNavigate()
const { login, isAuthed, session } = useAuth()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
async function onSubmit(e: FormEvent) {
e.preventDefault()
setError('')
try {
await login(email, password)
navigate('/workspace')
} catch {
setError('登录失败,请检查账号密码')
}
}
return (
<section>
<h2></h2>
<p> Center token </p>
{isAuthed ? <p>{session?.user.email}</p> : null}
<form onSubmit={onSubmit} style={{ display: 'grid', gap: 8, maxWidth: 420 }}>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" type="email" />
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
type="password"
/>
<button type="submit"></button>
</form>
<p>{error}</p>
</section>
)
}

View File

@@ -33,6 +33,11 @@ export default function WorkspacePage() {
<section>
<h2></h2>
<form onSubmit={onSave} style={{ display: 'grid', gap: 8, maxWidth: 760 }}>
<input
value={form.centerApiBase}
onChange={(e) => setForm((v) => ({ ...v, centerApiBase: e.target.value }))}
placeholder="Center API Base"
/>
<input
value={form.guildApiBase}
onChange={(e) => setForm((v) => ({ ...v, guildApiBase: e.target.value }))}