feat: build vehicle data platform and production pipeline
This commit is contained in:
50
vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx
Normal file
50
vehicle-data-platform/apps/web/src/v2/auth/AuthGate.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createContext, FormEvent, ReactNode, useContext, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { clearAccessToken, getAccessToken, PlatformSession, setAccessToken } from './session';
|
||||
|
||||
type AuthContextValue = {
|
||||
session: PlatformSession;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function usePlatformSession() {
|
||||
const value = useContext(AuthContext);
|
||||
if (!value) throw new Error('usePlatformSession must be used inside AuthGate');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [tokenVersion, setTokenVersion] = useState(0);
|
||||
const [draftToken, setDraftToken] = useState('');
|
||||
const [attempted, setAttempted] = useState(() => Boolean(getAccessToken()));
|
||||
const session = useQuery({
|
||||
queryKey: ['platform-session', tokenVersion],
|
||||
queryFn: api.session,
|
||||
retry: false,
|
||||
staleTime: Infinity
|
||||
});
|
||||
|
||||
const login = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setAccessToken(draftToken);
|
||||
setAttempted(true);
|
||||
setTokenVersion((value) => value + 1);
|
||||
};
|
||||
const logout = () => {
|
||||
clearAccessToken();
|
||||
setDraftToken('');
|
||||
setAttempted(false);
|
||||
setTokenVersion((value) => value + 1);
|
||||
};
|
||||
|
||||
if (session.isPending) {
|
||||
return <div className="v2-auth-screen"><div className="v2-auth-card"><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}><div className="v2-auth-mark">车</div><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>;
|
||||
}
|
||||
return <AuthContext.Provider value={{ session: session.data, logout }}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
22
vehicle-data-platform/apps/web/src/v2/auth/session.test.ts
Normal file
22
vehicle-data-platform/apps/web/src/v2/auth/session.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { afterEach, expect, test } from 'vitest';
|
||||
import { canAdminister, canOperate, clearAccessToken, getAccessToken, setAccessToken } from './session';
|
||||
|
||||
afterEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
test('access token is scoped to the browser session and can be cleared', () => {
|
||||
setAccessToken(' secret-token ');
|
||||
expect(getAccessToken()).toBe('secret-token');
|
||||
expect(window.localStorage.length).toBe(0);
|
||||
clearAccessToken();
|
||||
expect(getAccessToken()).toBe('');
|
||||
});
|
||||
|
||||
test('role helpers follow the server permission hierarchy', () => {
|
||||
expect(canOperate({ name: 'v', role: 'viewer', authMode: 'enforce' })).toBe(false);
|
||||
expect(canOperate({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(true);
|
||||
expect(canAdminister({ name: 'o', role: 'operator', authMode: 'enforce' })).toBe(false);
|
||||
expect(canAdminister({ name: 'a', role: 'admin', authMode: 'enforce' })).toBe(true);
|
||||
});
|
||||
31
vehicle-data-platform/apps/web/src/v2/auth/session.ts
Normal file
31
vehicle-data-platform/apps/web/src/v2/auth/session.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
const TOKEN_KEY = 'vehicle-platform.access-token';
|
||||
|
||||
export type PlatformRole = 'viewer' | 'operator' | 'admin';
|
||||
|
||||
export interface PlatformSession {
|
||||
name: string;
|
||||
role: PlatformRole;
|
||||
authMode: 'disabled' | 'enforce';
|
||||
}
|
||||
|
||||
export function getAccessToken() {
|
||||
return window.sessionStorage.getItem(TOKEN_KEY) ?? '';
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string) {
|
||||
const normalized = token.trim();
|
||||
if (normalized) window.sessionStorage.setItem(TOKEN_KEY, normalized);
|
||||
else window.sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function clearAccessToken() {
|
||||
window.sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function canOperate(session: PlatformSession) {
|
||||
return session.role === 'operator' || session.role === 'admin';
|
||||
}
|
||||
|
||||
export function canAdminister(session: PlatformSession) {
|
||||
return session.role === 'admin';
|
||||
}
|
||||
Reference in New Issue
Block a user