diff --git a/.env.example b/.env.example index 5ec4dc0..0fff91a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/src/App.tsx b/src/App.tsx index b2feab3..bf11332 100644 --- a/src/App.tsx +++ b/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() { }> } /> - } /> - } /> + + + + } + /> + + + + } + /> } /> } /> diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx new file mode 100644 index 0000000..6b104c2 --- /dev/null +++ b/src/auth/AuthContext.tsx @@ -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(getAuthSession()) + + const value = useMemo( + () => ({ + 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 {children} +} diff --git a/src/auth/ProtectedRoute.tsx b/src/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..f7a0dad --- /dev/null +++ b/src/auth/ProtectedRoute.tsx @@ -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 + return <>{children} +} diff --git a/src/auth/auth-context.ts b/src/auth/auth-context.ts new file mode 100644 index 0000000..74b8dbf --- /dev/null +++ b/src/auth/auth-context.ts @@ -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 + logout: () => Promise + ensureFreshToken: () => Promise +} + +export const AuthContext = createContext(null) + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used inside ') + return ctx +} diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index a7142e3..3d2bf2f 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -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 (
diff --git a/src/lib/auth-storage.ts b/src/lib/auth-storage.ts new file mode 100644 index 0000000..8a84ff4 --- /dev/null +++ b/src/lib/auth-storage.ts @@ -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 +} diff --git a/src/lib/center-auth-client.ts b/src/lib/center-auth-client.ts new file mode 100644 index 0000000..070897d --- /dev/null +++ b/src/lib/center-auth-client.ts @@ -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 { + const res = await centerClient().post('/auth/login', payload) + return res.data +} + +export async function refreshCenter(refreshToken: string): Promise { + const res = await centerClient().post('/auth/refresh', { refreshToken }) + return res.data +} + +export async function logoutCenter(refreshToken: string): Promise { + await centerClient().post('/auth/logout', { refreshToken }) +} diff --git a/src/lib/runtime-config.ts b/src/lib/runtime-config.ts index 4f3f427..96e7e78 100644 --- a/src/lib/runtime-config.ts +++ b/src/lib/runtime-config.ts @@ -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 ?? '', diff --git a/src/main.tsx b/src/main.tsx index ade9d64..28ed1a9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( - + + + , ) diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 262f3e3..9331ac3 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -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 (

登录

-

下一步接入 Center 登录与 token 管理。

+ {isAuthed ?

当前用户:{session?.user.email}

: null} +
+ setEmail(e.target.value)} placeholder="Email" type="email" /> + setPassword(e.target.value)} + placeholder="Password" + type="password" + /> + +
+

{error}

) } diff --git a/src/pages/WorkspacePage.tsx b/src/pages/WorkspacePage.tsx index f127a40..f2a1d65 100644 --- a/src/pages/WorkspacePage.tsx +++ b/src/pages/WorkspacePage.tsx @@ -33,6 +33,11 @@ export default function WorkspacePage() {

工作台

+ setForm((v) => ({ ...v, centerApiBase: e.target.value }))} + placeholder="Center API Base" + /> setForm((v) => ({ ...v, guildApiBase: e.target.value }))}