feat(frontend): implement center auth session flow with route guard
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
VITE_CENTER_API_BASE=http://localhost:7001/api
|
||||
VITE_GUILD_API_BASE=http://localhost:7002/api
|
||||
VITE_GUILD_SOCKET_BASE=http://localhost:7002/realtime
|
||||
VITE_API_KEY=change-me-api-key
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -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
52
src/auth/AuthContext.tsx
Normal 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>
|
||||
}
|
||||
34
src/auth/ProtectedRoute.tsx
Normal file
34
src/auth/ProtectedRoute.tsx
Normal 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
18
src/auth/auth-context.ts
Normal 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
|
||||
}
|
||||
@@ -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
47
src/lib/auth-storage.ts
Normal 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
|
||||
}
|
||||
40
src/lib/center-auth-client.ts
Normal file
40
src/lib/center-auth-client.ts
Normal 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 })
|
||||
}
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 }))}
|
||||
|
||||
Reference in New Issue
Block a user