Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ target
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/
TODO.md
.DS_Store
103 changes: 71 additions & 32 deletions cockpit/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useState, lazy, Suspense } from "react";
import { AppShell, Group, NavLink, Button, Modal, TextInput, Stack, Text, Loader } from "@mantine/core";
import { useEffect, useState, lazy, Suspense } from "react";
import { AppShell, Group, NavLink, Button, Center, Text, Loader } from "@mantine/core";
import { Routes, Route, NavLink as RouterNavLink, Navigate, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { getToken, setToken } from "./api/client";
import { api, setToken } from "./api/client";
import { Logo } from "./components/Logo";
import { LoginPage } from "./pages/Login";
import { PrincipalsPage } from "./pages/Principals";
import { PortfoliosPage } from "./pages/Portfolios";
import { AccountsPage } from "./pages/Accounts";
Expand All @@ -13,6 +14,7 @@ import { BlotterPage } from "./pages/Blotter";
import { ReconciliationPage } from "./pages/Reconciliation";
import { InstrumentsPage } from "./pages/Instruments";
import { DataFeedsPage } from "./pages/DataFeeds";
import { ApiPage } from "./pages/Api";

// Heavy bundles (Scalar, Mermaid) — only load when their doc page is opened.
const ApiDocsPage = lazy(() => import("./pages/ApiDocs").then((m) => ({ default: m.ApiDocsPage })));
Expand All @@ -22,6 +24,7 @@ const ArchitecturePage = lazy(() =>

const NAV = [
{ to: "/principals", label: "Principals" },
{ to: "/tokens", label: "API" },
{ to: "/portfolios", label: "Portfolios" },
{ to: "/accounts", label: "Accounts" },
{ to: "/broker-connections", label: "Broker connections" },
Expand All @@ -38,30 +41,6 @@ const DOCS_NAV = [
{ to: "/api-docs", label: "API docs" },
];

function TokenModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const [value, setValue] = useState(getToken());
return (
<Modal opened={opened} onClose={onClose} title="Admin API token" centered>
<Stack>
<Text size="sm" c="dimmed">
The OMS admin token (OMS_ADMIN_TOKEN). Leave blank in dev when
OMS_ADMIN_AUTH_ENABLED=false. Stored in this browser only.
</Text>
<TextInput
label="Bearer token"
placeholder="token…"
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>Cancel</Button>
<Button onClick={() => { setToken(value.trim()); onClose(); }}>Save</Button>
</Group>
</Stack>
</Modal>
);
}

function ConnectionDot() {
const { data: ok } = useQuery({
queryKey: ["health"],
Expand All @@ -76,8 +55,8 @@ function ConnectionDot() {
);
}

export function App() {
const [tokenOpen, setTokenOpen] = useState(false);
/** The authenticated console. */
function Console({ onLogout }: { onLogout: () => void }) {
const { pathname } = useLocation();
return (
<AppShell header={{ height: 56 }} navbar={{ width: 220, breakpoint: "sm" }} padding="md">
Expand All @@ -86,8 +65,8 @@ export function App() {
<Logo />
<Group gap="md">
<ConnectionDot />
<Button variant="light" size="xs" onClick={() => setTokenOpen(true)}>
{getToken() ? "Token set" : "Set token"}
<Button variant="subtle" size="xs" color="gray" onClick={onLogout}>
Log out
</Button>
</Group>
</Group>
Expand Down Expand Up @@ -118,6 +97,7 @@ export function App() {
<Routes>
<Route path="/" element={<Navigate to="/principals" replace />} />
<Route path="/principals" element={<PrincipalsPage />} />
<Route path="/tokens" element={<ApiPage />} />
<Route path="/portfolios" element={<PortfoliosPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/broker-connections" element={<BrokerConnectionsPage />} />
Expand All @@ -144,7 +124,66 @@ export function App() {
/>
</Routes>
</AppShell.Main>
<TokenModal opened={tokenOpen} onClose={() => setTokenOpen(false)} />
</AppShell>
);
}

type Status = "checking" | "authed" | "login";

/**
* Auth gate. Probes an authed endpoint rather than just checking for a token, so
* dev with OMS_ADMIN_AUTH_ENABLED=false still enters straight through (no token
* needed), while a real deployment shows the login screen on 401/403.
*/
export function App() {
const [status, setStatus] = useState<Status>("checking");
const [checking, setChecking] = useState(false);
const [error, setError] = useState<string | undefined>();

async function probe(): Promise<boolean> {
try {
await api.get("/admin/principals");
return true;
} catch {
return false;
}
}

useEffect(() => {
probe().then((ok) => setStatus(ok ? "authed" : "login"));
}, []);

useEffect(() => {
const onUnauthorized = () => setStatus("login");
window.addEventListener("oms:unauthorized", onUnauthorized);
return () => window.removeEventListener("oms:unauthorized", onUnauthorized);
}, []);

if (status === "checking") {
return (
<Center h="100vh" bg="#0d1014">
<Loader />
</Center>
);
}

if (status === "login") {
return (
<LoginPage
checking={checking}
error={error}
onSubmit={async (token) => {
setToken(token);
setError(undefined);
setChecking(true);
const ok = await probe();
setChecking(false);
if (ok) setStatus("authed");
else setError("Invalid token");
}}
/>
);
}

return <Console onLogout={() => { setToken(""); setStatus("login"); }} />;
}
6 changes: 6 additions & 0 deletions cockpit/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
});

if (!res.ok) {
// Auth failed/expired: drop the stored token and let the app fall back to the
// login gate (which listens for this event).
if (res.status === 401 || res.status === 403) {
setToken("");
window.dispatchEvent(new Event("oms:unauthorized"));
}
const text = await res.text().catch(() => "");
throw new ApiError(res.status, text || `${method} ${path} failed (${res.status})`);
}
Expand Down
18 changes: 18 additions & 0 deletions cockpit/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ export interface Grant {
updated_at: string;
}

// A single bearer token (Databento-style) minted under a principal. `token` once.
export interface TradingTokenCreated {
token: string;
key_id: string;
principal_id: string;
portfolio_id: string | null;
label: string | null;
}

export interface TradingTokenRow {
key_id: string;
label: string | null;
principal_id: string;
principal_code: string;
principal_name: string | null;
created_at: string;
}

export interface RiskLimit {
id: string;
portfolio_id: string;
Expand Down
Loading
Loading