162 lines
4.6 KiB
TypeScript
162 lines
4.6 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
|
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
|
|
|
export interface AuthUser {
|
|
id: string;
|
|
username: string;
|
|
display_name: string;
|
|
is_admin: boolean;
|
|
}
|
|
|
|
interface AuthContextValue {
|
|
user: AuthUser | null;
|
|
token: string | null;
|
|
isAuthenticated: boolean;
|
|
isLoading: boolean;
|
|
login: (username: string, password: string) => Promise<void>;
|
|
logout: () => void;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
export function useAuth(): AuthContextValue {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
|
return ctx;
|
|
}
|
|
|
|
const TOKEN_KEY = "epeekit-auth-token";
|
|
const REFRESH_KEY = "epeekit-refresh-token";
|
|
const USER_KEY = "epeekit-auth-user";
|
|
|
|
export function getStoredToken(): string | null {
|
|
if (typeof window === "undefined") return null;
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [token, setToken] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
const saveAuth = useCallback((accessToken: string, refreshToken: string, userData: AuthUser) => {
|
|
localStorage.setItem(TOKEN_KEY, accessToken);
|
|
localStorage.setItem(REFRESH_KEY, refreshToken);
|
|
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
|
setToken(accessToken);
|
|
setUser(userData);
|
|
}, []);
|
|
|
|
const clearAuth = useCallback(() => {
|
|
localStorage.removeItem(TOKEN_KEY);
|
|
localStorage.removeItem(REFRESH_KEY);
|
|
localStorage.removeItem(USER_KEY);
|
|
setToken(null);
|
|
setUser(null);
|
|
}, []);
|
|
|
|
// 尝试用 refresh token 获取新 access token
|
|
const tryRefresh = useCallback(async (): Promise<boolean> => {
|
|
const refreshToken = localStorage.getItem(REFRESH_KEY);
|
|
if (!refreshToken) return false;
|
|
try {
|
|
const resp = await fetch(`${API_URL}/api/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
});
|
|
if (!resp.ok) return false;
|
|
const data = await resp.json();
|
|
const savedUser = localStorage.getItem(USER_KEY);
|
|
if (savedUser && data.access_token) {
|
|
const userData = JSON.parse(savedUser) as AuthUser;
|
|
localStorage.setItem(TOKEN_KEY, data.access_token);
|
|
setToken(data.access_token);
|
|
setUser(userData);
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}, []);
|
|
|
|
// 启动时验证 token
|
|
useEffect(() => {
|
|
async function init() {
|
|
const savedToken = localStorage.getItem(TOKEN_KEY);
|
|
if (!savedToken) {
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch(`${API_URL}/api/auth/me`, {
|
|
headers: { Authorization: `Bearer ${savedToken}` },
|
|
});
|
|
if (resp.ok) {
|
|
const userData = await resp.json();
|
|
setToken(savedToken);
|
|
setUser(userData);
|
|
} else if (resp.status === 401) {
|
|
const refreshed = await tryRefresh();
|
|
if (!refreshed) clearAuth();
|
|
} else {
|
|
clearAuth();
|
|
}
|
|
} catch {
|
|
// 网络错误时保留本地缓存的用户信息,允许离线使用
|
|
const savedUser = localStorage.getItem(USER_KEY);
|
|
if (savedUser) {
|
|
setToken(savedToken);
|
|
setUser(JSON.parse(savedUser));
|
|
}
|
|
}
|
|
setIsLoading(false);
|
|
}
|
|
init();
|
|
}, [clearAuth, tryRefresh]);
|
|
|
|
const login = useCallback(async (username: string, password: string) => {
|
|
const resp = await fetch(`${API_URL}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username, password }),
|
|
});
|
|
if (!resp.ok) {
|
|
const err = await resp.json().catch(() => ({ detail: "登录失败" }));
|
|
throw new Error(err.detail || "登录失败");
|
|
}
|
|
const data = await resp.json();
|
|
saveAuth(data.access_token, data.refresh_token, data.user);
|
|
}, [saveAuth]);
|
|
|
|
const logout = useCallback(() => {
|
|
clearAuth();
|
|
}, [clearAuth]);
|
|
|
|
const value = useMemo<AuthContextValue>(
|
|
() => ({
|
|
user,
|
|
token,
|
|
isAuthenticated: !!token && !!user,
|
|
isLoading,
|
|
login,
|
|
logout,
|
|
}),
|
|
[user, token, isLoading, login, logout]
|
|
);
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|