feat: add customer authentication and scoped RBAC

This commit is contained in:
lingniu
2026-07-16 13:58:28 +08:00
parent 6d6c9ce534
commit a1195fb97d
28 changed files with 1738 additions and 97 deletions

View File

@@ -4,8 +4,8 @@ import { afterEach, expect, test, vi } from 'vitest';
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
import { AuthGate, usePlatformSession } from './AuthGate';
const mocks = vi.hoisted(() => ({ session: vi.fn() }));
vi.mock('../../api/client', () => ({ api: { session: mocks.session } }));
const mocks = vi.hoisted(() => ({ session: vi.fn(), login: vi.fn(), logout: vi.fn() }));
vi.mock('../../api/client', () => ({ api: { session: mocks.session, login: mocks.login, logout: mocks.logout } }));
function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
const { session, logout } = usePlatformSession();
@@ -21,10 +21,14 @@ function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
afterEach(() => {
cleanup();
mocks.session.mockReset();
mocks.login.mockReset();
mocks.logout.mockReset();
mocks.logout.mockResolvedValue({ loggedOut: true });
window.sessionStorage.clear();
});
test('treats login and logout as complete query and mutation cache boundaries', async () => {
mocks.logout.mockResolvedValue({ loggedOut: true });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
client.setQueryData(['previous-user-vehicle-data'], { plate: '粤A00001' });
client.getMutationCache().build(client, { mutationFn: async () => ({ ok: true }) });
@@ -33,11 +37,12 @@ test('treats login and logout as complete query and mutation cache boundaries',
if (getAccessToken() !== 'next-user-token') throw new Error('未登录');
return { name: 'next-user', role: 'viewer', authMode: 'enforce' };
});
mocks.login.mockResolvedValue({ accessToken: 'next-user-token', expiresAt: new Date().toISOString(), session: { name: 'next-user', role: 'customer' } });
render(<QueryClientProvider client={client}><AuthGate><ProtectedWorkspace onAbort={aborted} /></AuthGate></QueryClientProvider>);
const token = await screen.findByPlaceholderText('Bearer token');
fireEvent.change(token, { target: { value: 'next-user-token' } });
fireEvent.click(screen.getByRole('button', { name: '进入平台' }));
fireEvent.change(await screen.findByPlaceholderText('请输入用户名'), { target: { value: 'next-user' } });
fireEvent.change(screen.getByPlaceholderText('请输入密码'), { target: { value: 'StrongPass!1' } });
fireEvent.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('next-user')).toBeInTheDocument();
expect(client.getQueryData(['previous-user-vehicle-data'])).toBeUndefined();
@@ -47,7 +52,7 @@ test('treats login and logout as complete query and mutation cache boundaries',
await waitFor(() => expect(client.getMutationCache().getAll()).toHaveLength(1));
fireEvent.click(screen.getByRole('button', { name: '退出测试会话' }));
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument();
expect(await screen.findByPlaceholderText('请输入用户名')).toBeInTheDocument();
await waitFor(() => expect(aborted).toHaveBeenCalledTimes(1));
expect(client.getQueryCache().find({ queryKey: ['protected-vehicle-data'] })).toBeUndefined();
expect(client.getMutationCache().getAll()).toHaveLength(0);
@@ -55,6 +60,7 @@ test('treats login and logout as complete query and mutation cache boundaries',
});
test('an expired protected request returns to login and removes the active user cache', async () => {
mocks.logout.mockResolvedValue({ loggedOut: true });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
setAccessToken('next-user-token');
mocks.session.mockImplementation(async () => {
@@ -67,7 +73,7 @@ test('an expired protected request returns to login and removes the active user
client.setQueryData(['active-user-detail'], { plate: '粤A00002' });
window.dispatchEvent(new Event(PLATFORM_UNAUTHORIZED_EVENT));
expect(await screen.findByPlaceholderText('Bearer token')).toBeInTheDocument();
expect(await screen.findByPlaceholderText('请输入用户名')).toBeInTheDocument();
expect(client.getQueryCache().find({ queryKey: ['active-user-detail'] })).toBeUndefined();
expect(getAccessToken()).toBe('');
});

View File

@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createContext, FormEvent, ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
import { api } from '../../api/client';
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
@@ -19,8 +19,13 @@ export function usePlatformSession() {
export function AuthGate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [tokenVersion, setTokenVersion] = useState(0);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [draftToken, setDraftToken] = useState('');
const [legacyMode, setLegacyMode] = useState(false);
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
const [loginPending, setLoginPending] = useState(false);
const [loginError, setLoginError] = useState('');
const session = useQuery({
queryKey: ['platform-session', tokenVersion],
queryFn: ({ signal }) => api.session(signal),
@@ -31,31 +36,73 @@ export function AuthGate({ children }: { children: ReactNode }) {
void queryClient.cancelQueries();
queryClient.clear();
}, [queryClient]);
const logout = useCallback(() => {
const finishLogout = useCallback(() => {
clearAccessToken();
clearClientSession();
setPassword('');
setDraftToken('');
setAttempted(false);
setLoginError('');
setTokenVersion((value) => value + 1);
}, [clearClientSession]);
const logout = useCallback(() => {
void api.logout().catch(() => undefined).finally(finishLogout);
}, [finishLogout]);
useEffect(() => {
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout);
return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, logout);
}, [logout]);
window.addEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout);
return () => window.removeEventListener(PLATFORM_UNAUTHORIZED_EVENT, finishLogout);
}, [finishLogout]);
const login = (event: FormEvent) => {
const login = async (event: FormEvent) => {
event.preventDefault();
setAccessToken(draftToken);
clearClientSession();
setAttempted(true);
setTokenVersion((value) => value + 1);
setLoginError('');
setLoginPending(true);
try {
if (legacyMode) {
setAccessToken(draftToken);
} else {
const result = await api.login({ username: username.trim(), password });
setAccessToken(result.accessToken);
}
clearClientSession();
setTokenVersion((value) => value + 1);
} catch (error) {
clearAccessToken();
setLoginError(error instanceof Error ? error.message : '登录失败,请稍后重试');
} finally {
setLoginPending(false);
}
};
if (session.isPending) {
return <div className="v2-auth-screen"><div className="v2-auth-card"><img className="v2-auth-logo" src="/brand-logo.svg" alt="牛智能" /><i className="v2-auth-spinner" /><strong>访</strong></div></div>;
return <div className="v2-auth-screen"><div className="v2-auth-card v2-auth-loading"><img className="v2-auth-logo" src="/brand-logo.svg" alt="牛智能" /><i className="v2-auth-spinner" /><strong></strong></div></div>;
}
if (!session.data) {
return <div className="v2-auth-screen"><form className="v2-auth-card" onSubmit={login}><img className="v2-auth-logo" src="/brand-logo.svg" alt="灵牛智能" /><h1></h1><p>访</p><label><span>访</span><input autoFocus required type="password" autoComplete="current-password" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label>{attempted && session.error ? <em>{session.error.message}</em> : null}<button type="submit" disabled={!draftToken.trim()}></button></form></div>;
const error = loginError || (attempted && session.error ? session.error.message : '');
return <div className="v2-auth-screen">
<section className="v2-auth-intro" aria-hidden="true">
<img src="/brand-logo.svg" alt="" />
<h2><br /></h2>
<p></p>
<div><span></span><span></span><span></span></div>
</section>
<form className="v2-auth-card" onSubmit={login}>
<img className="v2-auth-logo" src="/brand-logo.svg" alt="羚牛智能" />
<h1></h1>
<p>{legacyMode ? '仅供平台运维使用,输入服务器访问令牌。' : '使用管理员为你开通的账号登录。'}</p>
{legacyMode ? <label><span>访</span><input autoFocus required type="password" autoComplete="off" value={draftToken} onChange={(event) => setDraftToken(event.target.value)} placeholder="Bearer token" /></label> : <>
<label><span></span><input autoFocus required autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} placeholder="请输入用户名" /></label>
<label><span></span><input required type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" /></label>
</>}
{error ? <em role="alert">{error}</em> : null}
<button type="submit" disabled={loginPending || (legacyMode ? !draftToken.trim() : !username.trim() || !password)}>{loginPending ? '正在登录…' : '登录'}</button>
<button className="v2-auth-mode-switch" type="button" onClick={() => { setLegacyMode((value) => !value); setLoginError(''); }}>
{legacyMode ? '返回账号密码登录' : '使用运维令牌登录'}
</button>
<small></small>
</form>
</div>;
}
return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>;
}

View File

@@ -1,13 +1,7 @@
const TOKEN_KEY = 'vehicle-platform.access-token';
export const PLATFORM_UNAUTHORIZED_EVENT = 'vehicle-platform:unauthorized';
export type PlatformRole = 'viewer' | 'operator' | 'admin';
export interface PlatformSession {
name: string;
role: PlatformRole;
authMode: 'disabled' | 'enforce';
}
export type PlatformSession = SessionInfo;
export function getAccessToken() {
return window.sessionStorage.getItem(TOKEN_KEY) ?? '';
@@ -34,6 +28,13 @@ export function canOperate(session: PlatformSession) {
return session.role === 'operator' || session.role === 'admin';
}
export function hasMenu(session: PlatformSession, menu: string) {
if (session.role === 'admin') return true;
if (!session.menuKeys) return session.role !== 'customer';
return session.menuKeys.includes(menu);
}
export function canAdminister(session: PlatformSession) {
return session.role === 'admin';
}
import type { SessionInfo } from '../../api/types';