Admin — Authentication & Tenancy
Every page in the admin app runs inside two nested layouts: _authed (is-signed-in guard) and _authed/_tenant (tenant-resolved shell). Understanding how those two layers work explains why the admin app feels the way it does — why pages load without a separate sign-in flow per page, why every query carries a tenant id, and where the realtime subscription plumbing lives.
Sign-in
Section titled “Sign-in”Route: /sign-in
Sign-in is Cognito-backed with two entry points:
- Email / password — direct Cognito user pool authentication
- Google SSO — federated identity through Cognito’s Google provider, completing at
/auth/callback
After authentication, the app stores the JWT in-memory, kicks off a background refresh loop (see Token refresh), redirects to the authenticated layout, and preserves any next query parameter so the user lands on the page they were trying to reach.
The sign-in page itself is small — a logo, the two entry points, and any invite-flow context (if the user was sent from a tenant invitation). Invites are handled at /invite/:token and flow through a separate adapter-selection → join-request → API-key-claim path before landing at sign-in.
The _authed guard
Section titled “The _authed guard”File: apps/admin/src/routes/_authed.tsx
The _authed layout is a thin guard. Its job is:
- Call
getCurrentSession()from the auth context - If there’s no session,
throw redirect({ to: "/sign-in", search: { next: <current path> } }) - Otherwise, render children
Anything under _authed/... in the route tree is automatically behind this gate. That’s why individual pages don’t need their own auth check — they inherit it.
The _authed/_tenant layout
Section titled “The _authed/_tenant layout”File: apps/admin/src/routes/_authed/_tenant.tsx
This is the big one. Every page in the Work, Agents, and Manage groups lives under this layout. It owns:
- The sidebar navigation (Work / Agents / Manage / Other groups)
- The top bar with tenant switcher, command palette, notifications, theme toggle, and user menu
- The breadcrumb bar driven by
BreadcrumbContextso each page can set its own trail - Global dialogs —
CreateThreadDialogandNewAgentDialogare mounted at the layout level and triggered via context hooks (openNewThread(),openNewAgent()), not through a route change - The
AppSyncSubscriptionProviderthat manages live subscription connections for the whole session
Rendering this layout also guarantees that useTenant() resolves to a real tenant id before any child page fetches data.
Tenant resolution
Section titled “Tenant resolution”File: apps/admin/src/lib/TenantContext.tsx
Tenant resolution is a one-shot fetch on app boot:
- After sign-in, the app hits
/api/tenants/me - The response carries the tenant id, name, slug, and any tenant-level flags
- Those are stored in React context via
useTenant() - Every tenant-scoped GraphQL query reads
tenantIdfrom that context as a default variable
If the /api/tenants/me call fails or returns an empty tenant, the layout surfaces a hard error and the user cannot proceed. Operators who manage multiple tenants switch by choosing a different tenant in the top bar — the JWT is reused, the tenant context is swapped, and all queries re-fetch.
JWT plumbing
Section titled “JWT plumbing”File: apps/admin/src/lib/graphql-client.ts
The admin app uses urql with three exchanges: cacheExchange → fetchExchange → subscriptionExchange.
HTTP requests
Section titled “HTTP requests”Every HTTP request (GraphQL queries and mutations, REST calls under /api/...) goes through fetchOptions(), which returns:
{ headers: { Authorization: cachedToken, "x-tenant-id": tenantId, },}cachedToken is a closure variable updated by the background refresh loop. It’s read synchronously at request time so every call gets the current token without awaiting anything.
Token refresh
Section titled “Token refresh”startTokenRefresh() runs every 5 minutes in the background, calling getIdToken() from the auth context and updating cachedToken. The refresh is fire-and-forget from the request path’s point of view — requests never wait on it.
AppSync subscriptions
Section titled “AppSync subscriptions”Subscriptions use AppSync’s custom WebSocket protocol, not standard graphql-ws. The subscription exchange converts the HTTP URL (VITE_GRAPHQL_HTTP_URL) into the realtime subdomain (appsync-realtime-api), opens a WebSocket, performs the AppSync connection_init handshake with the JWT in the connection payload, then starts individual subscription streams as pages mount them.
Three subscriptions fire across most of the app:
OnThreadUpdatedSubscription— any thread changedOnThreadTurnUpdatedSubscription— any run/turn state transitionOnAgentStatusChangedSubscription— an agent came online, went idle, or was stopped
Each subscription re-executes the relevant queries (threads list, agent detail, dashboard metrics) with network-only request policy so the UI stays live without a manual refresh.
Shared layout components
Section titled “Shared layout components”The admin app leans heavily on a small set of reusable primitives. Every page uses some or all of these:
| Component | Purpose |
|---|---|
PageLayout | Wraps every tenant page. Holds the header + scrollable content area |
PageHeader | Title + description + optional action buttons (used consistently) |
PageSkeleton | Shimmer loading placeholder while the first query resolves |
EmptyState | Icon + title + description + optional CTA for empty lists |
DataTable | TanStack Table wrapper — sortable, filterable, paginated, row-click navigable |
StatusBadge | Status → color mapping for thread statuses, agent statuses, and run statuses |
Badge | Generic label badge (outline / secondary variants) |
Dialog / Sheet / Popover | Radix primitives for modals, side panels, dropdowns |
Tabs | Tab panel wrapper used for agent detail, analytics views, and security tabs |
InlineEditor | In-place text editing with save-on-blur for titles and descriptions |
The sidebar groups — Work, Agents, Manage, Other — are defined in AppSidebar and match the top-level route tree under _authed/_tenant.
Global state
Section titled “Global state”The admin app uses a mix of urql for server state and Zustand for client-side caches:
- urql cache — every GraphQL query and mutation goes through urql’s normalized cache with configurable request policies (
cache-first,cache-and-network,network-only) useCostStore— Zustand store that caches cost data by agent; hydrated by the dashboard and analytics views so subsequent pages don’t refetchuseActiveTurnsStore— tracks which threads currently have running turns, used by the “running” shimmer indicator in the threads listuseTenant(),useAuth(),useBreadcrumbs(),useDialog(),usePanel()— context hooks for layout-scoped state
No Redux, no MobX — the split between urql (server state) and a handful of Zustand stores (client-only caches) is deliberate and kept small.
Notable routes outside the authenticated tree
Section titled “Notable routes outside the authenticated tree”Three routes live outside _authed:
/sign-in— the entry point (above)/auth/callback— the OAuth redirect landing for Google SSO; exchanges the code for tokens and routes into the app/invite/:token— the public agent-invite flow (adapter selection, join request, API key claim) used for BYOB agent registration
These pages do not have access to the tenant context because they run before tenant resolution.
Known limits
Section titled “Known limits”- No mid-session refresh retry. If a request returns 401 because the token expired between refresh cycles, the app signs the user out rather than silently refreshing and retrying.
- Multi-tenant UI is tenant-at-a-time. Operators with access to several tenants switch one at a time in the top bar; there is no “all tenants” aggregated view.
- Development bypass. Local dev environments can bypass the Cognito gate via a config flag, which is convenient but means the sign-in flow isn’t exercised every time a developer runs the app locally.
Related pages
Section titled “Related pages”- Admin Overview — the full map of pages
- Mobile — Authentication — the mobile-side sign-in flow (Google OAuth + pre-signup Lambda)
- Architecture — where the admin app sits in the three-tier deployment model
- Deploy Configuration — the Cognito user pool and identity provider setup