Skip to main content

Add a frontend feature

The Atlas Console follows consistent patterns: pages are thin, data flows through React Query hooks, client UI state lives in Zustand stores, and everything is tenant-scoped. This guide covers the common additions and the matching backend endpoint. Read Frontend architecture first.

Add a page / route

  1. Create frontend/src/pages/MyPage.tsx. Use useTenant() / useTenantId() for tenant context.
  2. Register the route inside the protected route tree in App.tsx (under ProtectedLayout, which is guarded by RequireAuth).
  3. Add a sidebar item in components/layout/AppShell.tsx (one of the sections: Overview, Portfolio, Casework, Explore, Compliance Studio). Admin-only items are gated on user.roles.admin.

Add an API module + hook

Hooks wrap React Query and always include tenantId in the query key so caches can't bleed across tenants.

// frontend/src/api/myApi.ts
import { apiClient } from './client';
export const myApi = {
list: (filters?: Record<string, unknown>) =>
apiClient.get('/my-endpoint', { params: filters }).then(r => r.data),
};

// frontend/src/api/queryKeys.ts (add a domain)
myData: {
all: (tenantId: string) => ['myData', tenantId] as const,
list: (tenantId: string, f?: unknown) => ['myData', tenantId, 'list', f] as const,
},

// frontend/src/hooks/useMyData.ts
export function useMyData(filters?: Record<string, unknown>) {
const tenantId = useTenantId();
return useQuery({
queryKey: queryKeys.myData.list(tenantId, filters),
queryFn: () => myApi.list(filters),
staleTime: 60_000,
});
}

The shared apiClient already attaches the Keycloak bearer token and transparently refreshes + retries once on a 401. For long-running work, follow the poll pattern — e.g. investigations poll every 5s while active, and useReport retries on 202 Accepted up to 60×.

Add a Zustand store (with tenant reset)

Client UI state goes in a Zustand store — and must implement reset() wired to tenant change, so switching tenants can't leak UI state.

// frontend/src/stores/myStore.ts
export const useMyStore = create<MyState>((set) => ({
selectedId: null,
setSelectedId: (id) => set({ selectedId: id }),
reset: () => set({ selectedId: null }),
}));

Then register the reset in auth/TenantProvider.tsx alongside the others:

useMyStore.getState().reset(); // in the tenant-change effect

Add a report tab / visualization

Report visualizations transform API data into Cytoscape elements or Recharts data. For a graph, build {nodes, edges} with type/risk classes (e.g. legalentity, person, pep, sanctioned, center) and render CytoscapeGraph with a layout (fcose, dagre, cose-bilkent, breadthfirst). Add the tab to pages/ReportView.tsx and export the component from the report barrel.

Backend — add a tenant-scoped endpoint

Every endpoint must run through the tenant session so Row-Level Security applies.

# src/api/my_router.py — representative
from fastapi import APIRouter, Depends
from ..api.auth import AuthContext, require_tenant
from ..database.tenant_session import tenant_db_session

router = APIRouter(prefix="/my-feature")

@router.get("/items")
async def list_items(auth: AuthContext = Depends(require_tenant)):
async with tenant_db_session(auth.tenant_id, auth.tenant_realm) as conn:
rows = await conn.fetch("SELECT id, name FROM my_items ORDER BY created_at DESC")
return [dict(r) for r in rows]

Then app.include_router(router) in src/api/main.py.

Invariants

  • Tenant scope everywhere: query keys include tenantId; backend queries go through tenant_db_session; stores reset on tenant change.
  • Never store tokens in localStorage: the auth token lives in memory and is refreshed on a 60s margin.
  • Admin gating: Studio/Settings surfaces check user.roles.admin.

See Frontend architecture and Security & multi-tenancy.