feat(auth): add configurable password login with SSO default
ci/woodpecker/push/woodpecker Pipeline was successful

Co-authored-by: HiFox Agent <agents-noreply@hifox.com>
This commit is contained in:
kfluous
2026-09-05 18:22:41 +08:00
co-authored by HiFox Agent
parent 4430fb75f1
commit 0dc7e89c1d
12 changed files with 244 additions and 15 deletions
+34 -8
View File
@@ -5,6 +5,7 @@ import { setTokenGetter } from './api-client';
const AUTH_API = '/api/auth';
export default function AuthProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<'sso' | 'password'>('sso');
const [state, setState] = useState<AuthState>({
isLoading: true,
isAuthenticated: false,
@@ -19,8 +20,6 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
setTokenGetter(() => tokenRef.current);
// 防止 StrictMode 双重调用(jumpToken 一次性使用)
if (authStarted.current) return;
authStarted.current = true;
// 监听 401 事件
const onUnauthorized = () => {
@@ -30,14 +29,25 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
};
window.addEventListener('auth:unauthorized', onUnauthorized);
authenticate();
if (!authStarted.current) { authStarted.current = true; authenticate(); }
return () => window.removeEventListener('auth:unauthorized', onUnauthorized);
}, []);
async function authenticate() {
let currentMode: 'sso' | 'password';
try {
const response = await fetch(`${AUTH_API}/config`, { cache: 'no-store' });
const config = await response.json();
if (!response.ok || !['sso', 'password'].includes(config.mode)) throw new Error();
currentMode = config.mode;
setMode(currentMode);
} catch {
setState({ isLoading: false, isAuthenticated: false, user: null, error: '无法获取验证配置,请刷新重试' });
return;
}
// 本地开发免登录开关:.env 里设 VITE_DEV_BYPASS_AUTH=1 启用,仅 dev 生效
if (import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
if (currentMode === 'sso' && import.meta.env.DEV && import.meta.env.VITE_DEV_BYPASS_AUTH === '1') {
setState({
isLoading: false,
isAuthenticated: true,
@@ -59,15 +69,15 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
tokenRef.current = savedToken;
// 验证 token 是否仍然有效(尝试请求 health)
try {
const res = await fetch('/api/health', {
const res = await fetch(`${AUTH_API}/me`, {
headers: { Authorization: `Bearer ${savedToken}` },
});
if (res.ok) {
const savedUser = sessionStorage.getItem('bi_user');
const savedUser = await res.json();
setState({
isLoading: false,
isAuthenticated: true,
user: savedUser ? JSON.parse(savedUser) : null,
user: savedUser,
error: null,
});
return;
@@ -75,6 +85,12 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
} catch { /* token 无效,继续流程 */ }
sessionStorage.removeItem('bi_jwt');
sessionStorage.removeItem('bi_user');
tokenRef.current = null;
}
if (currentMode === 'password') {
setState({ isLoading: false, isAuthenticated: false, user: null, error: null });
return;
}
// 2. 从 URL 提取 jumpToken
@@ -119,8 +135,18 @@ export default function AuthProvider({ children }: { children: ReactNode }) {
}
}
async function loginWithPassword(password: string) {
const response = await fetch(`${AUTH_API}/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) });
const data = await response.json();
if (!response.ok || !data.token) throw new Error(data.message || '登录失败');
tokenRef.current = data.token;
sessionStorage.setItem('bi_jwt', data.token);
sessionStorage.setItem('bi_user', JSON.stringify(data.user));
setState({ isLoading: false, isAuthenticated: true, user: data.user, error: null });
}
return (
<AuthContext.Provider value={state}>
<AuthContext.Provider value={{ ...state, mode, loginWithPassword }}>
{children}
</AuthContext.Provider>
);