feat(web): split SPA from oikos binary, require auth on every route
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled

Phase 0 of plans/2026-07-12-wails-desktop-app.md. The control-room SPA
is no longer embedded (web/embed.go deleted); it's a standalone static
build served separately (make ui / make deploy-ui). The api process
adds CORS and drops the dev-open auth bypass — every route now needs a
real bearer token, including SSE (?token= query param, EventSource
can't set headers) and api's own /agent proxy to nomos (previously
unauthenticated by omission).

nomos was an unauthenticated client of api's /mcp and approval-decision
endpoints; closing dev-open would have broken it, so it now sends
Authorization: Bearer $OIKOS_MCP_BEARER_TOKEN on every call back to api.

SPA gets a runtime config module (config.ts) and a Config.svelte
first-launch/reconfigure page, reachable afterwards via a "Connection"
entry in the sidebar footer. Every fetch() in api.ts routes through
fetchWithAuth so the same build works same-origin (browser prod, Vite
dev proxy) or cross-origin (future Wails webview, remote access).

Six gaps found against the plan and the live Caddy topology while
implementing — documented in the plan's "Plan review" section, most
notably: api's own /agent mount was never behind combinedAuth (fixed),
and production's Authentik forward-auth needs a bearer-token bypass for
API routes that this repo's Caddyfile.oikos reference copy now has, but
the real dtoro/caddy-conf deploy does not yet.

Verified live: cross-origin static SPA + API, CORS, bearer auth, SSE
query-token auth, and localStorage persistence all confirmed working
in-browser. Full Go test suite and npm run build pass with no
regressions against the pre-change baseline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 15:49:42 +02:00
parent 346eb2f144
commit 0c0f35a3a9
32 changed files with 661 additions and 248 deletions

View File

@@ -8,9 +8,11 @@
import EntityDetail from './pages/EntityDetail.svelte'
import Knowledge from './pages/Knowledge.svelte'
import Learning from './pages/Learning.svelte'
import Config from './pages/Config.svelte'
import { newChat } from '$lib/stores/chat'
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
import { connectionState } from '$lib/stores/events'
import { isConfigured } from '$lib/config'
import { onMount } from 'svelte'
import * as Sidebar from '$lib/components/ui/sidebar'
import * as Sheet from '$lib/components/ui/sheet'
@@ -27,10 +29,12 @@
import NetworkIcon from '@lucide/svelte/icons/share-2'
import SearchIcon from '@lucide/svelte/icons/search'
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
import SettingsIcon from '@lucide/svelte/icons/settings'
let page = $state('overview')
let routeParam = $state('')
let drawerOpen = $state(false)
let configured = $state(isConfigured())
const approvalsPending = $derived($summary?.approvals_pending ?? 0)
const openSignals = $derived(openSignalCount($summary))
@@ -44,12 +48,14 @@
}
sync()
window.addEventListener('hashchange', sync)
const unsubscribeCtx = subscribeContext()
return () => window.removeEventListener('hashchange', sync)
})
return () => {
window.removeEventListener('hashchange', sync)
unsubscribeCtx()
}
// Context (dashboard summary + approvals poll) and the SSE stream both
// authenticate — don't subscribe until a token exists.
$effect(() => {
if (!configured) return
return subscribeContext()
})
function navigate(p: string) {
@@ -67,6 +73,13 @@
]
</script>
{#if !configured}
<Config
onConnected={() => (configured = true)}
onCancel={isConfigured() ? () => (configured = true) : undefined}
/>
{:else}
<Toaster />
<Sidebar.Provider class="h-svh" style="--header-height: calc(var(--spacing) * 12);">
@@ -145,6 +158,16 @@
<PanelRightIcon />
<span>Chat drawer</span>
</Button>
<Button
variant="ghost"
size="sm"
class="justify-start gap-2"
onclick={() => (configured = false)}
title="Server connection settings"
>
<SettingsIcon />
<span>Connection</span>
</Button>
</Sidebar.Footer>
</Sidebar.Root>
@@ -218,3 +241,5 @@
</div>
</Sheet.Content>
</Sheet.Root>
{/if}

View File

@@ -1,3 +1,10 @@
import { fetchWithAuth } from './config'
// Path prefixes only — NOT resolved URLs. fetchWithAuth resolves the actual
// origin (relative vs. configured apiUrl) fresh on every call via
// config.ts's apiBase(), so these can't be pre-resolved once at module load
// (the config may not be known yet at import time, e.g. before Config.svelte
// or a Wails-injected __OIKOS_CONFIG__ runs).
const BASE = '/agent'
const API = '/api/v1'
@@ -26,21 +33,21 @@ export interface Message {
}
export async function fetchSessions(): Promise<Session[]> {
const res = await fetch(`${BASE}/sessions`)
const res = await fetchWithAuth(`${BASE}/sessions`)
if (!res.ok) return []
const data = await res.json()
return data.sessions ?? []
}
export async function fetchMessages(sessionId: string): Promise<Message[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}`)
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`)
if (!res.ok) return []
const data = await res.json()
return data.messages ?? []
}
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
}
@@ -57,7 +64,7 @@ export interface PlanStep {
}
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}/plan`)
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/plan`)
if (!res.ok) return []
const data = await res.json()
return data.steps ?? []
@@ -74,16 +81,15 @@ export interface SessionQuestion {
}
export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> {
const res = await fetch(`${BASE}/sessions/${sessionId}/questions`)
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions`)
if (!res.ok) return []
const data = await res.json()
return data.questions ?? []
}
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
const res = await fetchWithAuth(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer })
})
return res.ok
@@ -105,9 +111,8 @@ export function streamChat(
): AbortController {
const controller = new AbortController()
fetch(`${BASE}/chat`, {
fetchWithAuth(`${BASE}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, session_id: sessionId ?? undefined }),
signal: controller.signal
}).then(async (res) => {
@@ -161,7 +166,7 @@ export interface DashboardSummary {
}
export async function fetchDashboardSummary(): Promise<DashboardSummary | null> {
const res = await fetch(`${API}/dashboard/summary`)
const res = await fetchWithAuth(`${API}/dashboard/summary`)
if (!res.ok) return null
return res.json()
}
@@ -194,7 +199,7 @@ export async function fetchEntities(filters: EntityFilters = {}): Promise<Entity
if (filters.state) params.set('state', filters.state)
if (filters.q) params.set('q', filters.q)
params.set('limit', '200')
const res = await fetch(`${API}/entities?${params}`)
const res = await fetchWithAuth(`${API}/entities?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -210,7 +215,7 @@ export async function fetchEvents(filters: EventFilters = {}): Promise<import('.
if (filters.type) params.set('type', filters.type)
if (filters.severity) params.set('severity', filters.severity)
params.set('limit', '100')
const res = await fetch(`${API}/events?${params}`)
const res = await fetchWithAuth(`${API}/events?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -235,7 +240,7 @@ export async function fetchApprovals(status?: string): Promise<Approval[]> {
const params = new URLSearchParams()
if (status) params.set('status', status)
params.set('limit', '200')
const res = await fetch(`${API}/approvals?${params}`)
const res = await fetchWithAuth(`${API}/approvals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -246,9 +251,8 @@ export async function decideApproval(
decision: 'approve' | 'deny' | 'revoke',
note?: string
): Promise<Approval | null> {
const res = await fetch(`${API}/approvals/${id}/decision`, {
const res = await fetchWithAuth(`${API}/approvals/${id}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note })
})
if (!res.ok) return null
@@ -277,20 +281,20 @@ export async function fetchExecutions(status?: string): Promise<Execution[]> {
const params = new URLSearchParams()
if (status) params.set('status', status)
params.set('limit', '200')
const res = await fetch(`${API}/executions?${params}`)
const res = await fetchWithAuth(`${API}/executions?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function getExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}`)
const res = await fetchWithAuth(`${API}/executions/${id}`)
if (!res.ok) return null
return res.json()
}
export async function cancelExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' })
const res = await fetchWithAuth(`${API}/executions/${id}/cancel`, { method: 'POST' })
if (!res.ok) return null
return res.json()
}
@@ -309,7 +313,7 @@ export interface ActivityItem {
}
export async function fetchRecentActivity(limit = 50): Promise<ActivityItem[]> {
const res = await fetch(`${API}/activity/recent?limit=${limit}`)
const res = await fetchWithAuth(`${API}/activity/recent?limit=${limit}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -325,7 +329,7 @@ export interface SessionDigest {
}
export async function fetchSessionDigest(sessionId: string): Promise<SessionDigest | null> {
const res = await fetch(`${API}/activity/session/${sessionId}`)
const res = await fetchWithAuth(`${API}/activity/session/${sessionId}`)
if (!res.ok) return null
return res.json()
}
@@ -338,7 +342,7 @@ export interface CapabilityTimelineItem {
}
export async function fetchLearningTimeline(): Promise<CapabilityTimelineItem[]> {
const res = await fetch(`${API}/learning/timeline`)
const res = await fetchWithAuth(`${API}/learning/timeline`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -351,7 +355,7 @@ export interface TrendBucket {
}
export async function fetchLearningTrend(): Promise<TrendBucket[]> {
const res = await fetch(`${API}/learning/trend`)
const res = await fetchWithAuth(`${API}/learning/trend`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -372,7 +376,7 @@ export interface Pattern {
}
export async function fetchPatterns(): Promise<Pattern[]> {
const res = await fetch(`${API}/patterns`)
const res = await fetchWithAuth(`${API}/patterns`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -390,7 +394,7 @@ export interface Skill {
}
export async function fetchSkills(): Promise<Skill[]> {
const res = await fetch(`${API}/skills`)
const res = await fetchWithAuth(`${API}/skills`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -418,22 +422,21 @@ export async function fetchSignals(filters: { state?: string; severity?: string
if (filters.state) params.set('state', filters.state)
if (filters.severity) params.set('severity', filters.severity)
params.set('limit', '200')
const res = await fetch(`${API}/signals?${params}`)
const res = await fetchWithAuth(`${API}/signals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function ackSignal(id: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/ack`, { method: 'POST' })
const res = await fetchWithAuth(`${API}/signals/${id}/ack`, { method: 'POST' })
if (!res.ok) return null
return res.json()
}
export async function resolveSignal(id: string, note?: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/resolve`, {
const res = await fetchWithAuth(`${API}/signals/${id}/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note })
})
if (!res.ok) return null
@@ -441,9 +444,8 @@ export async function resolveSignal(id: string, note?: string): Promise<Signal |
}
export async function muteSignal(id: string, muteUntil: string, note?: string): Promise<Signal | null> {
const res = await fetch(`${API}/signals/${id}/mute`, {
const res = await fetchWithAuth(`${API}/signals/${id}/mute`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mute_until: muteUntil, note })
})
if (!res.ok) return null
@@ -481,7 +483,7 @@ export async function fetchGraph(filters: GraphFilters = {}): Promise<GraphView
if (filters.depth) params.set('depth', String(filters.depth))
for (const rt of filters.relType ?? []) params.append('rel_type', rt)
if (filters.includeStatus) params.append('include', 'status')
const res = await fetch(`${API}/graph?${params}`)
const res = await fetchWithAuth(`${API}/graph?${params}`)
if (!res.ok) return null
return res.json()
}
@@ -492,14 +494,14 @@ export interface BlastRadiusItem {
}
export async function fetchBlastRadius(id: string): Promise<BlastRadiusItem[]> {
const res = await fetch(`${API}/entities/${id}/blast-radius`)
const res = await fetchWithAuth(`${API}/entities/${id}/blast-radius`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function fetchEntity(id: string): Promise<Entity | null> {
const res = await fetch(`${API}/entities/${id}`)
const res = await fetchWithAuth(`${API}/entities/${id}`)
if (!res.ok) return null
return res.json()
}
@@ -521,7 +523,7 @@ export interface MetricSeries {
export async function fetchMetrics(entityId: string): Promise<MetricSeries[]> {
const params = new URLSearchParams({ entity_id: entityId, rollup: 'auto' })
const res = await fetch(`${API}/metrics?${params}`)
const res = await fetchWithAuth(`${API}/metrics?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -559,13 +561,13 @@ export interface RecentKnowledge {
export async function fetchRecentKnowledge(source?: string): Promise<RecentKnowledge> {
const params = new URLSearchParams()
if (source) params.set('source', source)
const res = await fetch(`${API}/knowledge/recent?${params}`)
const res = await fetchWithAuth(`${API}/knowledge/recent?${params}`)
if (!res.ok) return { stats: { total: 0, by_kind: {}, agent_authored: 0, last_7d: 0 }, items: [] }
return res.json()
}
export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeHit[]> {
const res = await fetch(`${API}/knowledge/${entityId}`)
const res = await fetchWithAuth(`${API}/knowledge/${entityId}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -573,7 +575,7 @@ export async function fetchEntityKnowledge(entityId: string): Promise<KnowledgeH
export async function fetchEntityEvents(entityId: string): Promise<import('./stores/events').OikosEvent[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
const res = await fetch(`${API}/events?${params}`)
const res = await fetchWithAuth(`${API}/events?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -581,7 +583,7 @@ export async function fetchEntityEvents(entityId: string): Promise<import('./sto
export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
const params = new URLSearchParams({ entity_id: entityId, limit: '50' })
const res = await fetch(`${API}/signals?${params}`)
const res = await fetchWithAuth(`${API}/signals?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -589,7 +591,7 @@ export async function fetchEntitySignals(entityId: string): Promise<Signal[]> {
export async function fetchEntityExecutions(entityId: string): Promise<Execution[]> {
const params = new URLSearchParams({ target: entityId, limit: '50' })
const res = await fetch(`${API}/executions?${params}`)
const res = await fetchWithAuth(`${API}/executions?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -611,16 +613,16 @@ export interface Check {
export async function fetchChecksForTarget(targetSlug: string): Promise<Check[]> {
const params = new URLSearchParams({ target: targetSlug, limit: '50' })
const res = await fetch(`${API}/checks?${params}`)
const res = await fetchWithAuth(`${API}/checks?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
}
export async function patchCheck(id: string, version: number, patch: { enabled?: boolean; interval_s?: number; timeout_s?: number }): Promise<Check | null> {
const res = await fetch(`${API}/checks/${id}`, {
const res = await fetchWithAuth(`${API}/checks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'If-Match': `"${version}"` },
headers: { 'If-Match': `"${version}"` },
body: JSON.stringify(patch)
})
if (!res.ok) return null
@@ -654,7 +656,7 @@ export async function fetchAgentActivity(filters: {
if (filters.activity_type) params.set('activity_type', filters.activity_type)
if (filters.entity_id) params.set('entity_id', filters.entity_id)
params.set('limit', String(filters.limit ?? 200))
const res = await fetch(`${API}/agent-activity?${params}`)
const res = await fetchWithAuth(`${API}/agent-activity?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -662,7 +664,7 @@ export async function fetchAgentActivity(filters: {
export async function searchKnowledge(q: string, limit = 50): Promise<KnowledgeHit[]> {
const params = new URLSearchParams({ q, limit: String(limit) })
const res = await fetch(`${API}/knowledge/search?${params}`)
const res = await fetchWithAuth(`${API}/knowledge/search?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []
@@ -698,7 +700,7 @@ export async function fetchAudit(filters: {
if (filters.action) params.set('action', filters.action)
if (filters.correlation_id) params.set('correlation_id', filters.correlation_id)
params.set('limit', String(filters.limit ?? 200))
const res = await fetch(`${API}/audit?${params}`)
const res = await fetchWithAuth(`${API}/audit?${params}`)
if (!res.ok) return []
const data = await res.json()
return data.items ?? []

92
web/src/lib/config.ts Normal file
View File

@@ -0,0 +1,92 @@
// Runtime configuration for the SPA — server URL + auth token. Every fetch
// call goes through fetchWithAuth/apiBase (used by api.ts) so the SPA works
// identically whether it's served same-origin (browser prod, Vite dev proxy)
// or cross-origin (Wails webview, remote access). See
// plans/2026-07-12-wails-desktop-app.md 0.2.
export interface OikosConfig {
apiUrl: string // e.g. "https://oikos.hubris.network", or "" for same-origin
token?: string // bearer token for auth
}
declare global {
interface Window {
__OIKOS_CONFIG__?: OikosConfig
}
}
let cfg: OikosConfig | undefined
export function initConfig(override?: OikosConfig) {
cfg = override ?? window.__OIKOS_CONFIG__
if (cfg?.token) {
localStorage.setItem('oikos_token', cfg.token)
if (cfg.apiUrl) localStorage.setItem('oikos_api_url', cfg.apiUrl)
}
}
export function getConfig(): OikosConfig {
if (!cfg) {
const token = localStorage.getItem('oikos_token')
const apiUrl = localStorage.getItem('oikos_api_url')
if (token || apiUrl) {
cfg = { apiUrl: apiUrl ?? '', token: token ?? undefined }
}
}
return cfg ?? { apiUrl: '' }
}
export function setConfig(next: OikosConfig) {
cfg = next
if (next.token) localStorage.setItem('oikos_token', next.token)
else localStorage.removeItem('oikos_token')
if (next.apiUrl) localStorage.setItem('oikos_api_url', next.apiUrl)
else localStorage.removeItem('oikos_api_url')
}
export function clearConfig() {
cfg = { apiUrl: '' }
localStorage.removeItem('oikos_token')
localStorage.removeItem('oikos_api_url')
}
export function isConfigured(): boolean {
return !!getConfig().token
}
// Relative paths are used in dev (Vite proxy) and when the SPA shares an
// origin with the API server (Caddy reverse proxy). Absolute paths are used
// when the API server is on a different origin (Wails webview, remote access).
export function apiBase(path: string): string {
const c = getConfig()
if (!c.apiUrl) return path // relative — relies on same-origin or Vite proxy
return `${c.apiUrl}${path}`
}
// ---- Auth fetch wrapper ----
// Prepends the API base URL (absolute when configured, relative when unset
// for the Vite dev proxy / same-origin prod) and adds the Authorization
// header. Used by every fetch call in api.ts.
export async function fetchWithAuth(path: string, opts?: RequestInit): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(opts?.headers as Record<string, string> ?? {})
}
const c = getConfig()
if (c.token) {
headers['Authorization'] = `Bearer ${c.token}`
}
return fetch(apiBase(path), { ...opts, headers })
}
// SSE path builder — EventSource doesn't take headers, so pass the token as
// a query parameter (the SSE handler's combinedAuth checks it alongside the
// Authorization header, only for this route).
export function sseUrl(path: string): string {
const c = getConfig()
const url = apiBase(path)
if (!c.token) return url
const sep = url.includes('?') ? '&' : '?'
return `${url}${sep}token=${encodeURIComponent(c.token)}`
}

View File

@@ -1,4 +1,5 @@
import { writable } from 'svelte/store'
import { sseUrl } from '$lib/config'
export interface OikosEvent {
id: number
@@ -23,7 +24,7 @@ function connect() {
if (source) return
connectionState.set('connecting')
// The browser's EventSource sends Last-Event-ID automatically on reconnect.
source = new EventSource('/api/v1/events/stream')
source = new EventSource(sseUrl('/api/v1/events/stream'))
source.onopen = () => connectionState.set('open')

View File

@@ -1,6 +1,9 @@
import { mount } from 'svelte'
import App from './App.svelte'
import './app.css'
import { initConfig } from '$lib/config'
initConfig()
const app = mount(App, { target: document.getElementById('app')! })
export default app

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card'
import * as Tabs from '$lib/components/ui/tabs'
import { Input } from '$lib/components/ui/input'
import { Label } from '$lib/components/ui/label'
import { Button } from '$lib/components/ui/button'
import { fetchWithAuth, setConfig, initConfig, getConfig, clearConfig } from '$lib/config'
let { onConnected, onCancel }: { onConnected: () => void; onCancel?: () => void } = $props()
const existing = getConfig()
let apiUrl = $state(existing.apiUrl ?? '')
let token = $state(existing.token ?? '')
let connecting = $state(false)
let error = $state('')
function disconnect() {
clearConfig()
apiUrl = ''
token = ''
error = ''
}
async function connect() {
error = ''
if (!token.trim()) {
error = 'Token is required'
return
}
connecting = true
setConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
initConfig({ apiUrl: apiUrl.trim(), token: token.trim() })
try {
const res = await fetchWithAuth('/api/v1/dashboard/summary')
if (!res.ok) {
error = res.status === 401 ? 'Invalid token' : `Server responded ${res.status}`
return
}
onConnected()
} catch (e) {
error = 'Could not reach server — check the URL'
} finally {
connecting = false
}
}
</script>
<div class="flex h-svh items-center justify-center p-6">
<Card.Root class="w-full max-w-md">
<Card.Header>
<Card.Title>Connect to Oikos</Card.Title>
<Card.Description>Enter the server URL and your access token.</Card.Description>
</Card.Header>
<Card.Content>
<Tabs.Root value="token">
<Tabs.List class="mb-4 grid w-full grid-cols-2">
<Tabs.Trigger value="token">Token</Tabs.Trigger>
<Tabs.Trigger value="oidc" disabled>Login with Authentik (coming soon)</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="token">
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); connect() }}>
<div class="flex flex-col gap-1.5">
<Label for="server-url">Server URL</Label>
<Input
id="server-url"
type="url"
placeholder="https://oikos.hubris.network (leave blank if same-origin)"
bind:value={apiUrl}
/>
</div>
<div class="flex flex-col gap-1.5">
<Label for="token">Token</Label>
<Input id="token" type="password" placeholder="bearer token" bind:value={token} />
</div>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<div class="flex gap-2">
<Button type="submit" disabled={connecting} class="flex-1">
{connecting ? 'Connecting…' : 'Connect'}
</Button>
{#if onCancel}
<Button type="button" variant="outline" onclick={onCancel}>Cancel</Button>
{/if}
</div>
{#if existing.token}
<Button type="button" variant="ghost" size="sm" onclick={disconnect}>
Forget saved connection
</Button>
{/if}
</form>
</Tabs.Content>
</Tabs.Root>
</Card.Content>
</Card.Root>
</div>