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) │
|
* │ StatusRail (link health · active run · UTC clock · build) │
|
||||||
* ├──────────┬───────────────────────────────────────────────────┤
|
* ├──────────┬───────────────────────────────────────────────────┤
|
||||||
* │ │ │
|
|
||||||
* │ Sidebar │ Outlet (current screen) │
|
* │ Sidebar │ Outlet (current screen) │
|
||||||
* │ │ │
|
|
||||||
* └──────────┴───────────────────────────────────────────────────┘
|
* └──────────┴───────────────────────────────────────────────────┘
|
||||||
*
|
*
|
||||||
* Unauthenticated visitors are redirected to /login. Inside the shell, the
|
* Session resolution flow:
|
||||||
* sidebar and rail expose only what the current session's role can see —
|
* 1. useSession queries /api/v1/auth/me with the cookie that travels
|
||||||
* this is layout, not enforcement: the API remains the source of truth on
|
* automatically on every fetch.
|
||||||
* permissions (D-008 / F11).
|
* 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() {
|
export function AppShell() {
|
||||||
const { user } = useSession();
|
const { user, isLoading } = useSession();
|
||||||
const clock = useClock();
|
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) {
|
if (!user) {
|
||||||
return <Navigate to="/login" replace />;
|
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 { clsx } from 'clsx';
|
||||||
import { Logo } from '@/components/brand/Logo';
|
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';
|
import { ROLE_LABELS, isRT, isLead } from '@/types/roles';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -21,22 +22,36 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { user, signOut } = useSession();
|
const { user, signOut, isSigningOut } = useSession();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const canRT = isRT(user.role);
|
const canRT = isRT(user.role);
|
||||||
const canLead = isLead(user.role);
|
const canLead = isLead(user.role);
|
||||||
const visible = NAV_ITEMS.filter((item) => item.show({ canRT, canLead }));
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className="flex flex-col h-full w-56 border-r"
|
className="flex flex-col h-full w-56 border-r"
|
||||||
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
|
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
|
||||||
>
|
>
|
||||||
<div
|
<div className="px-3 py-4 border-b" style={{ borderColor: 'var(--line-default)' }}>
|
||||||
className="px-3 py-4 border-b"
|
|
||||||
style={{ borderColor: 'var(--line-default)' }}
|
|
||||||
>
|
|
||||||
<Logo build="0.1.0" />
|
<Logo build="0.1.0" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +108,7 @@ export function Sidebar() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-fg-default" style={{ fontSize: '12px' }}>
|
<div className="truncate text-fg-default" style={{ fontSize: '12px' }}>
|
||||||
{user.displayName}
|
{user.display_name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="label-system mt-0.5"
|
className="label-system mt-0.5"
|
||||||
@@ -104,16 +119,22 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={signOut}
|
onClick={handleSignOut}
|
||||||
className="label-system text-fg-faint hover:text-fg-default px-2 py-1"
|
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)' }}
|
style={{ border: '1px solid var(--line-default)', borderRadius: 'var(--radius-sm)' }}
|
||||||
>
|
>
|
||||||
Sign out
|
{isSigningOut ? '…' : 'Sign out'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono tabular text-fg-faint truncate" style={{ fontSize: '10px' }}>
|
{user.engagement_name && (
|
||||||
ENG · {user.engagementName}
|
<div
|
||||||
|
className="font-mono tabular text-fg-faint truncate"
|
||||||
|
style={{ fontSize: '10px' }}
|
||||||
|
>
|
||||||
|
ENG · {user.engagement_name}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface StatusRailProps {
|
|||||||
activeRunId?: string;
|
activeRunId?: string;
|
||||||
activeRunState?: 'running' | 'paused' | 'aborting' | 'idle';
|
activeRunState?: 'running' | 'paused' | 'aborting' | 'idle';
|
||||||
clock: string;
|
clock: string;
|
||||||
|
/** Optional session resolution indicator, shown while /auth/me is in flight. */
|
||||||
|
sessionState?: 'resolving';
|
||||||
}
|
}
|
||||||
|
|
||||||
const LINK_LABEL: Record<LinkState, string> = {
|
const LINK_LABEL: Record<LinkState, string> = {
|
||||||
@@ -37,6 +39,7 @@ export function StatusRail({
|
|||||||
activeRunId,
|
activeRunId,
|
||||||
activeRunState = 'idle',
|
activeRunState = 'idle',
|
||||||
clock,
|
clock,
|
||||||
|
sessionState,
|
||||||
}: StatusRailProps) {
|
}: StatusRailProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -76,6 +79,13 @@ export function StatusRail({
|
|||||||
|
|
||||||
<span className="flex-1" />
|
<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' }}>
|
<span className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
|
||||||
{clock}
|
{clock}
|
||||||
</span>
|
</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 { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||||
import { Root } from '@/routing/Root';
|
|
||||||
import { AppShell } from '@/components/shell/AppShell';
|
import { AppShell } from '@/components/shell/AppShell';
|
||||||
import { LoginPage } from '@/screens/login/LoginPage';
|
import { LoginPage } from '@/screens/login/LoginPage';
|
||||||
import { EngagementsPage } from '@/screens/engagements/EngagementsPage';
|
import { EngagementsPage } from '@/screens/engagements/EngagementsPage';
|
||||||
@@ -10,35 +9,31 @@ import { TtpLibraryPage } from '@/screens/library/TtpLibraryPage';
|
|||||||
import { AuditPage } from '@/screens/audit/AuditPage';
|
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
|
* Session state lives in TanStack Query (key `SESSION_QUERY_KEY`), mounted
|
||||||
* top-level paths (login, app shell, fallback) are children of that
|
* once in App.tsx via QueryClientProvider — no per-route provider needed.
|
||||||
* single Root so they share one session state — no provider forking
|
*
|
||||||
* between routes.
|
* 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
|
* Once real engagement scoping lands, sub-routes nest under
|
||||||
* /engagements/:eid (spec §9). Sprint 0 keeps URLs flat so the
|
* /engagements/:eid (spec §9). Sprint 1 keeps URLs flat.
|
||||||
* wireframes are reachable directly.
|
|
||||||
*/
|
*/
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{ index: true, element: <Navigate to="/engagements" replace /> },
|
||||||
element: <Root />,
|
{ path: '/login', element: <LoginPage /> },
|
||||||
children: [
|
|
||||||
{ index: true, element: <Navigate to="/login" replace /> },
|
|
||||||
{ path: 'login', element: <LoginPage /> },
|
|
||||||
{
|
{
|
||||||
element: <AppShell />,
|
element: <AppShell />,
|
||||||
children: [
|
children: [
|
||||||
{ path: 'engagements', element: <EngagementsPage /> },
|
{ path: '/engagements', element: <EngagementsPage /> },
|
||||||
{ path: 'library', element: <TtpLibraryPage /> },
|
{ path: '/library', element: <TtpLibraryPage /> },
|
||||||
{ path: 'scenarios', element: <ScenarioComposerPage /> },
|
{ path: '/scenarios', element: <ScenarioComposerPage /> },
|
||||||
{ path: 'runs', element: <LiveCockpitPage /> },
|
{ path: '/runs', element: <LiveCockpitPage /> },
|
||||||
{ path: 'reports', element: <ReportPage /> },
|
{ path: '/reports', element: <ReportPage /> },
|
||||||
{ path: 'audit', element: <AuditPage /> },
|
{ path: '/audit', element: <AuditPage /> },
|
||||||
],
|
|
||||||
},
|
|
||||||
{ path: '*', element: <Navigate to="/login" replace /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ 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 { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
|
||||||
import { SessionContext } from './SessionContext.context';
|
import type { User } from '@/types/api';
|
||||||
|
import { fetchMe, logout as logoutRequest } from './sessionApi';
|
||||||
|
|
||||||
export function useSession() {
|
export const SESSION_QUERY_KEY = ['session'] as const;
|
||||||
const ctx = useContext(SessionContext);
|
|
||||||
if (!ctx) {
|
interface UseSessionResult {
|
||||||
throw new Error('useSession must be used inside <SessionProvider>');
|
user: User | null;
|
||||||
}
|
isLoading: boolean;
|
||||||
return ctx;
|
isError: boolean;
|
||||||
|
signOut: () => Promise<void>;
|
||||||
|
isSigningOut: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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> = {
|
export const ROLE_LABELS: Record<Role, string> = {
|
||||||
rt_operator: 'RT Operator',
|
rt_operator: 'RT Operator',
|
||||||
rt_lead: 'RT Lead',
|
rt_lead: 'RT Lead',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const API_TARGET = process.env.VITE_DEV_API_TARGET ?? 'http://localhost:5000';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -13,5 +15,12 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: false,
|
strictPort: false,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: API_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user