Files
CamTalk/frontend/src/lib/auth.tsx
cfy666 8de956719c feat: 实现前端认证系统,WebSocket 连接携带 JWT token
- 新增 AuthContext/AuthProvider:登录/注册状态管理,JWT 自动刷新
- 新增 AuthPage 组件:登录/注册表单,支持模式切换和前端校验
- 新增 api.ts:封装 auth REST API 客户端(register/login/refresh/logout)
- WebSocket 连接时拼接 ?token=<jwt>,重连自动携带
- useVisionSession 接受 accessToken 参数并透传
- App.tsx 包裹 AuthProvider,未登录时显示登录页
- storage.ts 新增 token/user 的 localStorage 存储
- i18n 新增中/英/日三语 auth 翻译
- App.css 新增 auth 页面和用户 badge 样式
2026-06-14 18:38:45 +08:00

210 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// Auth Context — 认证状态管理
// 职责:登录/注册/登出/token 刷新,为子组件提供 auth 状态
// ============================================================
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import * as api from "./api";
import type { AuthUser } from "./api";
import {
clearAuth,
loadAccessToken,
loadRefreshToken,
loadUser,
saveAccessToken,
saveRefreshToken,
saveUser,
} from "./storage";
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
isLoading: boolean;
}
interface AuthContextValue extends AuthState {
login: (username: string, password: string) => Promise<{ error?: string }>;
register: (username: string, password: string) => Promise<{ error?: string }>;
logout: () => Promise<void>;
accessToken: string | null;
}
const AuthContext = createContext<AuthContextValue | null>(null);
/** Access token 提前刷新的缓冲时间(秒) */
const REFRESH_BUFFER_SEC = 60;
/** 解析 JWT payload不做签名验证 */
function parseJwtPayload(token: string): { exp?: number } | null {
try {
const base64 = token.split(".")[1];
const json = atob(base64.replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(loadUser);
const [accessToken, setAccessToken] = useState<string | null>(loadAccessToken);
const [isLoading, setIsLoading] = useState(true);
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 清除定时器
const clearRefreshTimer = useCallback(() => {
if (refreshTimerRef.current) {
clearTimeout(refreshTimerRef.current);
refreshTimerRef.current = null;
}
}, []);
// 持久化 token + user
const persistAuth = useCallback(
(authUser: AuthUser, access: string, refresh: string) => {
setUser(authUser);
setAccessToken(access);
saveAccessToken(access);
saveRefreshToken(refresh);
saveUser(authUser);
},
[]
);
// 安排自动刷新
const scheduleRefresh = useCallback(
(access: string) => {
clearRefreshTimer();
const payload = parseJwtPayload(access);
if (!payload?.exp) return;
const nowSec = Math.floor(Date.now() / 1000);
const delayMs = Math.max((payload.exp - nowSec - REFRESH_BUFFER_SEC) * 1000, 5000);
refreshTimerRef.current = setTimeout(async () => {
const rt = loadRefreshToken();
if (!rt) return;
const res = await api.refreshToken(rt);
if (res.data) {
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
scheduleRefresh(res.data.access_token);
} else {
// 刷新失败,清除 auth
clearAuth();
setUser(null);
setAccessToken(null);
}
}, delayMs);
},
[clearRefreshTimer, persistAuth]
);
// 初始化:检查已有 token 并尝试刷新
useEffect(() => {
const init = async () => {
const storedAccess = loadAccessToken();
const storedRefresh = loadRefreshToken();
const storedUser = loadUser();
if (!storedAccess || !storedRefresh || !storedUser) {
setIsLoading(false);
return;
}
// 检查 access token 是否过期
const payload = parseJwtPayload(storedAccess);
const nowSec = Math.floor(Date.now() / 1000);
if (payload?.exp && payload.exp > nowSec) {
// access token 仍然有效
setUser(storedUser);
setAccessToken(storedAccess);
scheduleRefresh(storedAccess);
} else {
// access token 过期,尝试 refresh
const res = await api.refreshToken(storedRefresh);
if (res.data) {
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
scheduleRefresh(res.data.access_token);
} else {
clearAuth();
}
}
setIsLoading(false);
};
init();
return () => clearRefreshTimer();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const login = useCallback(
async (username: string, password: string): Promise<{ error?: string }> => {
const res = await api.login(username, password);
if (res.data) {
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
scheduleRefresh(res.data.access_token);
return {};
}
return { error: res.error?.message || "登录失败" };
},
[persistAuth, scheduleRefresh]
);
const register = useCallback(
async (username: string, password: string): Promise<{ error?: string }> => {
const res = await api.register(username, password);
if (res.data) {
persistAuth(res.data.user, res.data.access_token, res.data.refresh_token);
scheduleRefresh(res.data.access_token);
return {};
}
return { error: res.error?.message || "注册失败" };
},
[persistAuth, scheduleRefresh]
);
const logout = useCallback(async () => {
const at = accessToken;
const rt = loadRefreshToken();
if (at && rt) {
await api.logout(at, rt);
}
clearRefreshTimer();
clearAuth();
setUser(null);
setAccessToken(null);
}, [accessToken, clearRefreshTimer]);
const value = useMemo<AuthContextValue>(
() => ({
user,
isAuthenticated: !!user && !!accessToken,
isLoading,
login,
register,
logout,
accessToken,
}),
[user, accessToken, isLoading, login, register, logout]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}