feat(frontend): wire session to real /auth/me + drop sessionStorage mock
Foundations for the sprint 1 backend wiring. No UI behavior change beyond the loading state in AppShell, but everything below the wire is now real: - vite.config.ts adds `server.proxy['/api']` → http://localhost:5000 (overridable via VITE_DEV_API_TARGET). In prod Caddy routes /api → backend on the same origin, so the same `/api/v1/...` paths work without changes. - src/types/api.ts hand-rolled against the backend Pydantic schemas. User / Engagement / EngagementCreate / Login / ApiError / ApiValidationError. Should be regenerated from OpenAPI once backend exposes it. - src/lib/api.ts: thin fetch wrapper. Always credentials:'include' so the HttpOnly session cookie travels. 4xx/5xx normalize into ApiClientError with typed `body` (ApiError | ApiValidationError | null). No retry loop — that's TanStack Query's policy. - src/session/sessionApi.ts: 1:1 functions for /auth/me, /auth/login, /auth/logout. fetchMe maps 401 → null so "unauthenticated" is data, not an error. - src/session/useSession.ts: now a TanStack Query hook against SESSION_QUERY_KEY (`['session']`). Returns { user, isLoading, isError, signOut, isSigningOut }. Cookie is the source of truth, server is the resolver, query is the cache. - Drop sessionStorage mock layer entirely: src/mocks/session.ts, src/session/SessionContext.{tsx,context.ts}, src/routing/Root.tsx all removed. No more provider tree — QueryClientProvider in App.tsx is the only global state container. - AppShell renders a "resolving session" state during /auth/me's first flight so users with a valid cookie don't see a /login flash on direct navigation to a protected URL. - StatusRail gains an optional `sessionState="resolving"` slot used by the loading shell. - Sidebar's Sign-out wires POST /auth/logout, invalidates the session cache, and always navigates to /login regardless of the call outcome (a failed logout still expires the local cache so users aren't stuck on a broken cookie). - types/roles.ts loses SessionUser (replaced by api.ts User which is the authoritative shape).
This commit is contained in:
@@ -11,20 +11,38 @@ import { useClock } from './useClock';
|
||||
* ┌──────────────────────────────────────────────────────────────┐
|
||||
* │ StatusRail (link health · active run · UTC clock · build) │
|
||||
* ├──────────┬───────────────────────────────────────────────────┤
|
||||
* │ │ │
|
||||
* │ Sidebar │ Outlet (current screen) │
|
||||
* │ │ │
|
||||
* └──────────┴───────────────────────────────────────────────────┘
|
||||
*
|
||||
* Unauthenticated visitors are redirected to /login. Inside the shell, the
|
||||
* sidebar and rail expose only what the current session's role can see —
|
||||
* this is layout, not enforcement: the API remains the source of truth on
|
||||
* permissions (D-008 / F11).
|
||||
* Session resolution flow:
|
||||
* 1. useSession queries /api/v1/auth/me with the cookie that travels
|
||||
* automatically on every fetch.
|
||||
* 2. While the query is in flight, the shell renders a minimal masthead
|
||||
* with a "resolving session" pill — avoids a /login flash for users
|
||||
* who land on a protected URL with a valid cookie.
|
||||
* 3. Resolved null → redirect to /login. Resolved User → render the
|
||||
* shell. Errors fall through to the same null path (treat hard
|
||||
* backend errors as unauthenticated for routing purposes, the
|
||||
* LoginPage surfaces the underlying message).
|
||||
*
|
||||
* Backend RBAC remains authoritative — the role enum here only drives
|
||||
* layout (which nav items appear).
|
||||
*/
|
||||
export function AppShell() {
|
||||
const { user } = useSession();
|
||||
const { user, isLoading } = useSession();
|
||||
const clock = useClock();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col" style={{ backgroundColor: 'var(--surface-0)' }}>
|
||||
<StatusRail clock={clock} sessionState="resolving" />
|
||||
<div className="flex-1 flex items-center justify-center label-system text-fg-faint">
|
||||
resolving session …
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { clsx } from 'clsx';
|
||||
import { Logo } from '@/components/brand/Logo';
|
||||
import { useSession } from '@/session/useSession';
|
||||
import { useSession, SESSION_QUERY_KEY } from '@/session/useSession';
|
||||
import { ROLE_LABELS, isRT, isLead } from '@/types/roles';
|
||||
|
||||
interface NavItem {
|
||||
@@ -21,22 +22,36 @@ const NAV_ITEMS: NavItem[] = [
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const { user, signOut } = useSession();
|
||||
const { user, signOut, isSigningOut } = useSession();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const canRT = isRT(user.role);
|
||||
const canLead = isLead(user.role);
|
||||
const visible = NAV_ITEMS.filter((item) => item.show({ canRT, canLead }));
|
||||
|
||||
// Logout is a one-shot side effect that always lands on /login regardless
|
||||
// of whether the backend call succeeded — a failed logout still expires
|
||||
// the local session cache so the user is not stuck on a broken cookie.
|
||||
const handleSignOut = () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch {
|
||||
queryClient.setQueryData(SESSION_QUERY_KEY, null);
|
||||
}
|
||||
void navigate('/login', { replace: true });
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex flex-col h-full w-56 border-r"
|
||||
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
|
||||
>
|
||||
<div
|
||||
className="px-3 py-4 border-b"
|
||||
style={{ borderColor: 'var(--line-default)' }}
|
||||
>
|
||||
<div className="px-3 py-4 border-b" style={{ borderColor: 'var(--line-default)' }}>
|
||||
<Logo build="0.1.0" />
|
||||
</div>
|
||||
|
||||
@@ -93,7 +108,7 @@ export function Sidebar() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-fg-default" style={{ fontSize: '12px' }}>
|
||||
{user.displayName}
|
||||
{user.display_name}
|
||||
</div>
|
||||
<div
|
||||
className="label-system mt-0.5"
|
||||
@@ -104,16 +119,22 @@ export function Sidebar() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={signOut}
|
||||
className="label-system text-fg-faint hover:text-fg-default px-2 py-1"
|
||||
onClick={handleSignOut}
|
||||
disabled={isSigningOut}
|
||||
className="label-system text-fg-faint hover:text-fg-default px-2 py-1 disabled:opacity-50"
|
||||
style={{ border: '1px solid var(--line-default)', borderRadius: 'var(--radius-sm)' }}
|
||||
>
|
||||
Sign out
|
||||
{isSigningOut ? '…' : 'Sign out'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="font-mono tabular text-fg-faint truncate" style={{ fontSize: '10px' }}>
|
||||
ENG · {user.engagementName}
|
||||
{user.engagement_name && (
|
||||
<div
|
||||
className="font-mono tabular text-fg-faint truncate"
|
||||
style={{ fontSize: '10px' }}
|
||||
>
|
||||
ENG · {user.engagement_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ interface StatusRailProps {
|
||||
activeRunId?: string;
|
||||
activeRunState?: 'running' | 'paused' | 'aborting' | 'idle';
|
||||
clock: string;
|
||||
/** Optional session resolution indicator, shown while /auth/me is in flight. */
|
||||
sessionState?: 'resolving';
|
||||
}
|
||||
|
||||
const LINK_LABEL: Record<LinkState, string> = {
|
||||
@@ -37,6 +39,7 @@ export function StatusRail({
|
||||
activeRunId,
|
||||
activeRunState = 'idle',
|
||||
clock,
|
||||
sessionState,
|
||||
}: StatusRailProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -76,6 +79,13 @@ export function StatusRail({
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
{sessionState === 'resolving' && (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="status-dot text-fg-faint pulsing" />
|
||||
<span className="label-system tabular text-fg-faint">SESSION · RESOLVING</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
|
||||
{clock}
|
||||
</span>
|
||||
|
||||
93
frontend/src/lib/api.ts
Normal file
93
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Thin fetch wrapper for the Mimic backend.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Inject `credentials: 'include'` so the session cookie travels with
|
||||
* every call (the cookie itself is HttpOnly + Secure, set by the backend).
|
||||
* - Set JSON Content-Type on bodied requests and parse JSON responses.
|
||||
* - Normalize 4xx/5xx into a typed `ApiClientError` so callers can branch
|
||||
* on `error.status` and `error.detail` without duplicating parsing.
|
||||
*
|
||||
* Deliberate non-features:
|
||||
* - No retry loop. TanStack Query owns that policy.
|
||||
* - No CSRF token: same-origin in prod (Caddy), same-origin via Vite proxy
|
||||
* in dev. SameSite=Lax cookie is enough for our threat model (no
|
||||
* cross-site form posts in scope).
|
||||
*/
|
||||
|
||||
import type { ApiError, ApiValidationError } from '@/types/api';
|
||||
|
||||
const DEFAULT_BASE = '/api/v1';
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status: number;
|
||||
body: ApiError | ApiValidationError | null;
|
||||
|
||||
constructor(status: number, message: string, body: ApiError | ApiValidationError | null) {
|
||||
super(message);
|
||||
this.name = 'ApiClientError';
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiRequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function parseBody(response: Response): Promise<unknown> {
|
||||
if (response.status === 204) return null;
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function bodyAsApiError(body: unknown): ApiError | ApiValidationError | null {
|
||||
if (typeof body !== 'object' || body === null) return null;
|
||||
if (Array.isArray((body as { detail?: unknown }).detail)) {
|
||||
return body as ApiValidationError;
|
||||
}
|
||||
if (typeof (body as { detail?: unknown }).detail === 'string') {
|
||||
return body as ApiError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, opts: ApiRequestOptions = {}): Promise<T> {
|
||||
const url = path.startsWith('http') ? path : `${DEFAULT_BASE}${path}`;
|
||||
const method = opts.method ?? 'GET';
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
credentials: 'include',
|
||||
signal: opts.signal,
|
||||
});
|
||||
|
||||
const parsed = await parseBody(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiClientError(
|
||||
response.status,
|
||||
`${method} ${url} → ${response.status.toString()}`,
|
||||
bodyAsApiError(parsed),
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as T;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { SessionUser } from '@/types/roles';
|
||||
|
||||
/**
|
||||
* Sprint 0 mock — no backend yet. The session is selected from /login
|
||||
* and persisted in sessionStorage so route navigations preserve role.
|
||||
* Real auth lands later (D-003: local user/password v1, Keycloak OIDC v2).
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'mimic.mock.session';
|
||||
|
||||
export const MOCK_SESSIONS: Record<string, SessionUser> = {
|
||||
rt_operator: {
|
||||
id: 'usr_001',
|
||||
displayName: 'M. Dubreuil',
|
||||
role: 'rt_operator',
|
||||
engagementId: 'eng_42',
|
||||
engagementName: 'Démo Client X',
|
||||
},
|
||||
rt_lead: {
|
||||
id: 'usr_002',
|
||||
displayName: 'A. Verlhac',
|
||||
role: 'rt_lead',
|
||||
engagementId: 'eng_42',
|
||||
engagementName: 'Démo Client X',
|
||||
},
|
||||
soc_analyst: {
|
||||
id: 'usr_soc_07',
|
||||
displayName: 'SOC · session #07',
|
||||
role: 'soc_analyst',
|
||||
engagementId: 'eng_42',
|
||||
engagementName: 'Démo Client X',
|
||||
},
|
||||
};
|
||||
|
||||
export function readMockSession(): SessionUser | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
typeof parsed === 'object' &&
|
||||
parsed !== null &&
|
||||
'role' in parsed &&
|
||||
typeof (parsed as SessionUser).role === 'string'
|
||||
) {
|
||||
return parsed as SessionUser;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeMockSession(user: SessionUser): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export function clearMockSession(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { Root } from '@/routing/Root';
|
||||
import { AppShell } from '@/components/shell/AppShell';
|
||||
import { LoginPage } from '@/screens/login/LoginPage';
|
||||
import { EngagementsPage } from '@/screens/engagements/EngagementsPage';
|
||||
@@ -10,35 +9,31 @@ import { TtpLibraryPage } from '@/screens/library/TtpLibraryPage';
|
||||
import { AuditPage } from '@/screens/audit/AuditPage';
|
||||
|
||||
/**
|
||||
* Routes mirror spec §9 (UI Web) with sprint 0 placeholders.
|
||||
* Routes mirror spec §9 (UI Web).
|
||||
*
|
||||
* The Root route mounts SessionProvider once for the entire tree. All
|
||||
* top-level paths (login, app shell, fallback) are children of that
|
||||
* single Root so they share one session state — no provider forking
|
||||
* between routes.
|
||||
* Session state lives in TanStack Query (key `SESSION_QUERY_KEY`), mounted
|
||||
* once in App.tsx via QueryClientProvider — no per-route provider needed.
|
||||
*
|
||||
* AppShell is the gate for authenticated routes: it reads useSession() and
|
||||
* redirects to /login on null user. The login route lives outside the
|
||||
* shell so it stays reachable when unauthenticated.
|
||||
*
|
||||
* Once real engagement scoping lands, sub-routes nest under
|
||||
* /engagements/:eid (spec §9). Sprint 0 keeps URLs flat so the
|
||||
* wireframes are reachable directly.
|
||||
* /engagements/:eid (spec §9). Sprint 1 keeps URLs flat.
|
||||
*/
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
element: <Root />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/login" replace /> },
|
||||
{ path: 'login', element: <LoginPage /> },
|
||||
{ index: true, element: <Navigate to="/engagements" replace /> },
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ path: 'engagements', element: <EngagementsPage /> },
|
||||
{ path: 'library', element: <TtpLibraryPage /> },
|
||||
{ path: 'scenarios', element: <ScenarioComposerPage /> },
|
||||
{ path: 'runs', element: <LiveCockpitPage /> },
|
||||
{ path: 'reports', element: <ReportPage /> },
|
||||
{ path: 'audit', element: <AuditPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/login" replace /> },
|
||||
{ path: '/engagements', element: <EngagementsPage /> },
|
||||
{ path: '/library', element: <TtpLibraryPage /> },
|
||||
{ path: '/scenarios', element: <ScenarioComposerPage /> },
|
||||
{ path: '/runs', element: <LiveCockpitPage /> },
|
||||
{ path: '/reports', element: <ReportPage /> },
|
||||
{ path: '/audit', element: <AuditPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <Navigate to="/engagements" replace /> },
|
||||
]);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { SessionProvider } from '@/session/SessionContext';
|
||||
|
||||
/**
|
||||
* Root route element. Mounts SessionProvider once for the entire app so
|
||||
* every nested route — login, app shell, fallback — shares one session
|
||||
* state. Kept in its own file so router.tsx exports only the router
|
||||
* config (fast-refresh friendly).
|
||||
*/
|
||||
export function Root() {
|
||||
return (
|
||||
<SessionProvider>
|
||||
<Outlet />
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createContext } from 'react';
|
||||
import type { SessionUser } from '@/types/roles';
|
||||
|
||||
export interface SessionContextValue {
|
||||
user: SessionUser | null;
|
||||
signIn: (user: SessionUser) => void;
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
export const SessionContext = createContext<SessionContextValue | null>(null);
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { SessionUser } from '@/types/roles';
|
||||
import { clearMockSession, readMockSession, writeMockSession } from '@/mocks/session';
|
||||
import { SessionContext, type SessionContextValue } from './SessionContext.context';
|
||||
|
||||
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<SessionUser | null>(() => readMockSession());
|
||||
|
||||
const signIn = useCallback((next: SessionUser) => {
|
||||
writeMockSession(next);
|
||||
setUser(next);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
clearMockSession();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<SessionContextValue>(
|
||||
() => ({ user, signIn, signOut }),
|
||||
[user, signIn, signOut],
|
||||
);
|
||||
|
||||
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||
}
|
||||
32
frontend/src/session/sessionApi.ts
Normal file
32
frontend/src/session/sessionApi.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ApiClientError, apiFetch } from '@/lib/api';
|
||||
import type { LoginRequest, User } from '@/types/api';
|
||||
|
||||
/**
|
||||
* Network layer for session-scoped endpoints.
|
||||
*
|
||||
* Each function maps 1:1 to a backend endpoint and is the only place that
|
||||
* knows the path. Components never call `apiFetch` directly for these —
|
||||
* they go through TanStack Query against these helpers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/me — returns the current user if a valid session cookie
|
||||
* is present. Maps the 401 case to `null` rather than re-throwing so the
|
||||
* caller can treat "unauthenticated" as data, not as an error.
|
||||
*/
|
||||
export async function fetchMe(signal?: AbortSignal): Promise<User | null> {
|
||||
try {
|
||||
return await apiFetch<User>('/auth/me', { signal });
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError && err.status === 401) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(payload: LoginRequest): Promise<User> {
|
||||
return apiFetch<User>('/auth/login', { method: 'POST', body: payload });
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
await apiFetch<null>('/auth/logout', { method: 'POST' });
|
||||
}
|
||||
@@ -1,10 +1,52 @@
|
||||
import { useContext } from 'react';
|
||||
import { SessionContext } from './SessionContext.context';
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||
import type { User } from '@/types/api';
|
||||
import { fetchMe, logout as logoutRequest } from './sessionApi';
|
||||
|
||||
export function useSession() {
|
||||
const ctx = useContext(SessionContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSession must be used inside <SessionProvider>');
|
||||
export const SESSION_QUERY_KEY = ['session'] as const;
|
||||
|
||||
interface UseSessionResult {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
signOut: () => Promise<void>;
|
||||
isSigningOut: boolean;
|
||||
}
|
||||
return ctx;
|
||||
|
||||
/**
|
||||
* Single source of truth for the current session.
|
||||
*
|
||||
* Backed by TanStack Query against /api/v1/auth/me. Components never read
|
||||
* from sessionStorage or any local state — the cookie is the source, the
|
||||
* server is the resolver, the query is the cache.
|
||||
*
|
||||
* After successful login the LoginPage calls `queryClient.setQueryData` on
|
||||
* `SESSION_QUERY_KEY` with the returned User, which propagates to every
|
||||
* consumer instantly.
|
||||
*/
|
||||
export function useSession(): UseSessionResult {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useQuery<User | null>({
|
||||
queryKey: SESSION_QUERY_KEY,
|
||||
queryFn: ({ signal }) => fetchMe(signal),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: logoutRequest,
|
||||
onSuccess: () => {
|
||||
queryClient.setQueryData(SESSION_QUERY_KEY, null);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user: query.data ?? null,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
signOut: async () => {
|
||||
await logoutMutation.mutateAsync();
|
||||
},
|
||||
isSigningOut: logoutMutation.isPending,
|
||||
};
|
||||
}
|
||||
|
||||
68
frontend/src/types/api.ts
Normal file
68
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Shared API contract types.
|
||||
*
|
||||
* Hand-rolled for sprint 1 against the backend's Pydantic schemas. Once the
|
||||
* backend exposes OpenAPI, this file should be regenerated rather than
|
||||
* maintained by hand.
|
||||
*
|
||||
* The role enum mirrors the existing frontend/src/types/roles.ts so the
|
||||
* backend payload drops straight into the session state.
|
||||
*/
|
||||
|
||||
import type { Role } from './roles';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
role: Role;
|
||||
/** Optional engagement context. Present once the user is scoped to one. */
|
||||
engagement_id?: string;
|
||||
engagement_name?: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export type C2Type = 'mythic' | 'home';
|
||||
export type EngagementStatus = 'planning' | 'active' | 'reporting' | 'archived';
|
||||
|
||||
export interface Engagement {
|
||||
id: string;
|
||||
name: string;
|
||||
client_name: string | null;
|
||||
description: string | null;
|
||||
status: EngagementStatus;
|
||||
c2_type: C2Type | null;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface EngagementCreate {
|
||||
name: string;
|
||||
client_name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error shape returned by the backend on 422.
|
||||
* Pydantic v2 style: `detail` is an array of field errors.
|
||||
*/
|
||||
export interface ApiValidationError {
|
||||
detail: Array<{
|
||||
loc: Array<string | number>;
|
||||
msg: string;
|
||||
type: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic error shape for 4xx that are not validation errors
|
||||
* (401 invalid credentials, 403 forbidden, 404 not found, …).
|
||||
*/
|
||||
export interface ApiError {
|
||||
detail: string;
|
||||
}
|
||||
@@ -5,14 +5,6 @@
|
||||
*/
|
||||
export type Role = 'rt_operator' | 'rt_lead' | 'soc_analyst';
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
engagementId: string;
|
||||
engagementName: string;
|
||||
}
|
||||
|
||||
export const ROLE_LABELS: Record<Role, string> = {
|
||||
rt_operator: 'RT Operator',
|
||||
rt_lead: 'RT Lead',
|
||||
|
||||
@@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'node:path';
|
||||
|
||||
const API_TARGET = process.env.VITE_DEV_API_TARGET ?? 'http://localhost:5000';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
@@ -13,5 +15,12 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: false,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: API_TARGET,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user