feat: add customer authentication and scoped RBAC
This commit is contained in:
@@ -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('');
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user