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_API_BASE=http://localhost:7002/api
|
||||||
VITE_GUILD_SOCKET_BASE=http://localhost:7002/realtime
|
VITE_GUILD_SOCKET_BASE=http://localhost:7002/realtime
|
||||||
VITE_API_KEY=change-me-api-key
|
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 { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import ProtectedRoute from './auth/ProtectedRoute'
|
||||||
import AppLayout from './layouts/AppLayout'
|
import AppLayout from './layouts/AppLayout'
|
||||||
import ChatPage from './pages/ChatPage'
|
import ChatPage from './pages/ChatPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
@@ -9,8 +10,22 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<AppLayout />}>
|
<Route path="/" element={<AppLayout />}>
|
||||||
<Route index element={<Navigate to="/workspace" replace />} />
|
<Route index element={<Navigate to="/workspace" replace />} />
|
||||||
<Route path="workspace" element={<WorkspacePage />} />
|
<Route
|
||||||
<Route path="chat" element={<ChatPage />} />
|
path="workspace"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<WorkspacePage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="chat"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<ChatPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="login" element={<LoginPage />} />
|
<Route path="login" element={<LoginPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/workspace" replace />} />
|
<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 { Link, Outlet } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../auth/auth-context'
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
|
const { isAuthed, session, logout } = useAuth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '100vh' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '220px 1fr', minHeight: '100vh' }}>
|
||||||
<aside style={{ borderRight: '1px solid #ddd', padding: 16 }}>
|
<aside style={{ borderRight: '1px solid #ddd', padding: 16 }}>
|
||||||
@@ -10,6 +13,18 @@ export default function AppLayout() {
|
|||||||
<Link to="/chat">聊天</Link>
|
<Link to="/chat">聊天</Link>
|
||||||
<Link to="/login">登录</Link>
|
<Link to="/login">登录</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div style={{ marginTop: 16, fontSize: 12 }}>
|
||||||
|
{isAuthed ? (
|
||||||
|
<>
|
||||||
|
<div>{session?.user.email}</div>
|
||||||
|
<button onClick={() => logout()} style={{ marginTop: 6 }}>
|
||||||
|
登出
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span>未登录</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main style={{ padding: 16 }}>
|
<main style={{ padding: 16 }}>
|
||||||
<Outlet />
|
<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 = {
|
export type RuntimeConfig = {
|
||||||
|
centerApiBase: string
|
||||||
guildApiBase: string
|
guildApiBase: string
|
||||||
guildSocketBase: string
|
guildSocketBase: string
|
||||||
apiKey: string
|
apiKey: string
|
||||||
@@ -7,6 +8,7 @@ export type RuntimeConfig = {
|
|||||||
const KEY = 'fabric.runtime.config.v1'
|
const KEY = 'fabric.runtime.config.v1'
|
||||||
|
|
||||||
const defaults: RuntimeConfig = {
|
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',
|
guildApiBase: import.meta.env.VITE_GUILD_API_BASE ?? 'http://localhost:7002/api',
|
||||||
guildSocketBase: import.meta.env.VITE_GUILD_SOCKET_BASE ?? 'http://localhost:7002/realtime',
|
guildSocketBase: import.meta.env.VITE_GUILD_SOCKET_BASE ?? 'http://localhost:7002/realtime',
|
||||||
apiKey: import.meta.env.VITE_API_KEY ?? '',
|
apiKey: import.meta.env.VITE_API_KEY ?? '',
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client'
|
|||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { AuthProvider } from './auth/AuthContext'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</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() {
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2>登录</h2>
|
<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>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export default function WorkspacePage() {
|
|||||||
<section>
|
<section>
|
||||||
<h2>工作台</h2>
|
<h2>工作台</h2>
|
||||||
<form onSubmit={onSave} style={{ display: 'grid', gap: 8, maxWidth: 760 }}>
|
<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
|
<input
|
||||||
value={form.guildApiBase}
|
value={form.guildApiBase}
|
||||||
onChange={(e) => setForm((v) => ({ ...v, guildApiBase: e.target.value }))}
|
onChange={(e) => setForm((v) => ({ ...v, guildApiBase: e.target.value }))}
|
||||||
|
|||||||
Reference in New Issue
Block a user