The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 2307e7ec1ffeCommit detailsView latest

apps/dashboard/src/main.tsx 1446 lines · 47.6 KB · tsx
1
import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
import { createRoot } from 'react-dom/client';
3
import './styles.css';
4
5
declare const __AUTH0_DOMAIN__: string;
6
declare const __AUTH0_CLIENT_ID__: string;
7
declare const __AUTH0_AUDIENCE__: string;
8
9
type Auth0User = { sub: string; name?: string; email?: string; picture?: string };
10
type AuthSession = { user: Auth0User };
11
type AuthTransaction = { state: string; nonce: string; verifier: string };
12
type Theme = 'dark' | 'light';
13
14
const authTransactionKey = 'openclasp.auth0.transaction';
15
const themeKey = 'openclasp.theme.v1';
16
17
type DashboardData = {
18
  agents: Record<string, any>[];
19
  projects: Record<string, any>[];
20
  installations: Record<string, any>[];
21
  setupRequests: Record<string, any>[];
22
  publications: Record<string, any>[];
23
  interactions: Record<string, any>[];
24
  federatedInteractions: Record<string, any>[];
25
  liveSessions: Record<string, any>[];
26
  events: Record<string, any>[];
27
  conflicts: Record<string, any>[];
28
  receipts: Record<string, any>[];
29
  profiles: Record<string, any>[];
30
  runtimes: Record<string, any>[];
31
};
32
33
type Settings = {
34
  displayName: string;
35
  contributionEnabled: boolean;
36
  retentionDays: number;
37
  evidenceSharing: 'never' | 'ask' | 'contract_only';
38
  rawConversationsStored: false;
39
};
40
41
const emptyData: DashboardData = {
42
  agents: [],
43
  projects: [],
44
  installations: [],
45
  setupRequests: [],
46
  publications: [],
47
  interactions: [],
48
  federatedInteractions: [],
49
  liveSessions: [],
50
  events: [],
51
  conflicts: [],
52
  receipts: [],
53
  profiles: [],
54
  runtimes: [],
55
};
56
const defaultSettings: Settings = {
57
  displayName: '',
58
  contributionEnabled: false,
59
  retentionDays: 30,
60
  evidenceSharing: 'ask',
61
  rawConversationsStored: false,
62
};
63
const pages = ['dashboard', 'history', 'agents', 'insights', 'connect', 'settings'] as const;
64
type Page = (typeof pages)[number];
65
66
function base64Url(bytes: Uint8Array) {
67
  let binary = '';
68
  for (const byte of bytes) binary += String.fromCharCode(byte);
69
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
70
}
71
72
function randomValue(size = 32) {
73
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
74
}
75
76
async function beginAuth(provider: 'google' | 'github') {
77
  const verifier = randomValue(48);
78
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
79
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
80
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
81
  const parameters = new URLSearchParams({
82
    client_id: __AUTH0_CLIENT_ID__,
83
    redirect_uri: `${location.origin}/sso-callback`,
84
    response_type: 'code',
85
    scope: 'openid profile email',
86
    audience: __AUTH0_AUDIENCE__,
87
    state: transaction.state,
88
    nonce: transaction.nonce,
89
    code_challenge: base64Url(new Uint8Array(digest)),
90
    code_challenge_method: 'S256',
91
    connection: provider === 'google' ? 'google-oauth2' : 'github',
92
  });
93
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
94
}
95
96
async function signOut() {
97
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
98
  const parameters = new URLSearchParams({
99
    client_id: __AUTH0_CLIENT_ID__,
100
    returnTo: `${location.origin}/login`,
101
  });
102
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
103
}
104
105
async function api(path: string, init?: RequestInit) {
106
  const headers = new Headers(init?.headers);
107
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
108
  return fetch(path, {
109
    ...init,
110
    credentials: 'same-origin',
111
    headers,
112
  }).then(async (response) => {
113
    if (!response.ok)
114
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
115
    return response.json();
116
  });
117
}
118
119
function route(): Page {
120
  const value = location.pathname.slice(1) || 'dashboard';
121
  return pages.includes(value as Page) ? (value as Page) : 'dashboard';
122
}
123
124
function initialTheme(): Theme {
125
  const saved = localStorage.getItem(themeKey);
126
  if (saved === 'dark' || saved === 'light') return saved;
127
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
128
}
129
130
function App() {
131
  const [session, setSession] = useState<AuthSession | null>();
132
  const [theme, setTheme] = useState<Theme>(initialTheme);
133
  const [page, setPage] = useState<Page>(route());
134
  const [data, setData] = useState<DashboardData>(emptyData);
135
  const [settings, setSettings] = useState<Settings>(defaultSettings);
136
  const [loading, setLoading] = useState(true);
137
  const [error, setError] = useState('');
138
  const refreshDashboard = useCallback(async () => {
139
    setData((await api('/v0.1/dashboard')) as DashboardData);
140
  }, []);
141
142
  useEffect(() => {
143
    fetch('/api/session', { credentials: 'same-origin' })
144
      .then(async (response) => {
145
        if (!response.ok) return null;
146
        return (await response.json()) as AuthSession;
147
      })
148
      .then(setSession)
149
      .catch(() => setSession(null));
150
  }, []);
151
152
  useEffect(() => {
153
    document.documentElement.dataset.theme = theme;
154
    localStorage.setItem(themeKey, theme);
155
  }, [theme]);
156
157
  useEffect(() => {
158
    const onPopState = () => setPage(route());
159
    addEventListener('popstate', onPopState);
160
    return () => removeEventListener('popstate', onPopState);
161
  }, []);
162
163
  useEffect(() => {
164
    if (session === undefined) return;
165
    if (!session && location.pathname !== '/login') history.replaceState({}, '', '/login');
166
    if (!session) return;
167
    if (location.pathname === '/login') history.replaceState({}, '', '/dashboard');
168
    setPage(route());
169
    Promise.all([api('/v0.1/dashboard'), api('/v0.1/settings')])
170
      .then(([dashboard, accountSettings]) => {
171
        setData(dashboard as DashboardData);
172
        setSettings(accountSettings as Settings);
173
      })
174
      .catch((reason: unknown) =>
175
        setError(reason instanceof Error ? reason.message : 'Load failed'),
176
      )
177
      .finally(() => setLoading(false));
178
  }, [session]);
179
180
  useEffect(() => {
181
    if (!session) return;
182
    const refresh = () => {
183
      if (document.visibilityState === 'visible') void refreshDashboard().catch(() => undefined);
184
    };
185
    const timer = window.setInterval(refresh, 10_000);
186
    document.addEventListener('visibilitychange', refresh);
187
    return () => {
188
      window.clearInterval(timer);
189
      document.removeEventListener('visibilitychange', refresh);
190
    };
191
  }, [session, refreshDashboard]);
192
193
  const navigate = (next: Page) => {
194
    history.pushState({}, '', `/${next}`);
195
    setPage(next);
196
  };
197
198
  if (session === undefined) return <Loading />;
199
  if (!session) return <Login />;
200
201
  return (
202
    <div className="appShell">
203
      <aside>
204
        <div className="brand">
205
          <div className="mark">OC</div>
206
          <div>
207
            <strong>OpenClasp</strong>
208
            <small>ASSURANCE NETWORK</small>
209
          </div>
210
        </div>
211
        <nav>
212
          <Nav page="dashboard" active={page} onClick={navigate} label="Overview" glyph="⌂" />
213
          <Nav page="history" active={page} onClick={navigate} label="History" glyph="≡" />
214
          <Nav page="agents" active={page} onClick={navigate} label="Agents" glyph="◇" />
215
          <Nav page="insights" active={page} onClick={navigate} label="Insights" glyph="⌁" />
216
          <Nav page="connect" active={page} onClick={navigate} label="Connect" glyph="+" />
217
          <Nav page="settings" active={page} onClick={navigate} label="Settings" glyph="⚙" />
218
        </nav>
219
        <button
220
          className="themeSwitch"
221
          onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
222
          aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
223
        >
224
          <span>{theme === 'dark' ? '☼' : '◐'}</span>
225
          {theme === 'dark' ? 'Light theme' : 'Dark theme'}
226
        </button>
227
        <div className="privacyStamp">
228
          <span className="liveDot" />
229
          <div>
230
            <strong>Privacy separated</strong>
231
            <small>Raw messages never stored</small>
232
          </div>
233
        </div>
234
        <button className="account" onClick={() => void signOut()}>
235
          <span>{initials(session.user.name || session.user.email || 'OC')}</span>
236
          <div>
237
            <strong>{session.user.name || 'OpenClasp user'}</strong>
238
            <small>{session.user.email || 'Sign out'}</small>
239
          </div>
240
          <b>↗</b>
241
        </button>
242
      </aside>
243
      <main>
244
        {error && <div className="errorBar">{error}</div>}
245
        {loading ? (
246
          <Loading compact />
247
        ) : (
248
          <PageContent
249
            page={page}
250
            data={data}
251
            settings={settings}
252
            setSettings={setSettings}
253
            navigate={navigate}
254
            refreshDashboard={refreshDashboard}
255
          />
256
        )}
257
      </main>
258
    </div>
259
  );
260
}
261
262
function Login() {
263
  const [error, setError] = useState('');
264
  const continueWith = async (provider: 'google' | 'github') => {
265
    setError('');
266
    try {
267
      await beginAuth(provider);
268
    } catch {
269
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
270
    }
271
  };
272
  return (
273
    <div className="loginPage">
274
      <section className="loginPitch">
275
        <div className="brand">
276
          <div className="mark">OC</div>
277
          <strong>OpenClasp</strong>
278
        </div>
279
        <div>
280
          <p className="eyebrow">AGENT-TO-AGENT ASSURANCE</p>
281
          <h1>Know who your agent is dealing with.</h1>
282
          <p>
283
            Verified identities, signed outcomes, private warnings, and contextual
284
            reliability—without using message bodies for scoring.
285
          </p>
286
        </div>
287
        <div className="loginProof">
288
          <span>01</span> User-owned history<span>02</span> No universal trust score<span>03</span>{' '}
289
          Explicit network consent
290
        </div>
291
      </section>
292
      <section className="loginCard">
293
        <div>
294
          <p className="eyebrow">SECURE ACCESS</p>
295
          <h2>Sign in to OpenClasp</h2>
296
          <p>
297
            Manage connected agents, review signed history, and control what contributes to the
298
            network.
299
          </p>
300
        </div>
301
        <div className="socialButtons">
302
          <button onClick={() => void continueWith('google')}>
303
            <span className="google">G</span> Continue with Google
304
          </button>
305
          <button onClick={() => void continueWith('github')}>
306
            <span>◉</span> Continue with GitHub
307
          </button>
308
        </div>
309
        {error && <div className="loginError">{error}</div>}
310
        <small>
311
          Google and GitHub handle authentication. OpenClasp never receives your password.
312
        </small>
313
      </section>
314
    </div>
315
  );
316
}
317
318
let callbackStarted = false;
319
function AuthCallback() {
320
  const [error, setError] = useState('');
321
  useEffect(() => {
322
    if (callbackStarted) return;
323
    callbackStarted = true;
324
    const finish = async () => {
325
      const query = new URLSearchParams(location.search);
326
      const oauthError = query.get('error_description') ?? query.get('error');
327
      if (oauthError) throw new Error(oauthError);
328
      const code = query.get('code');
329
      const returnedState = query.get('state');
330
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
331
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
332
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
333
      sessionStorage.removeItem(authTransactionKey);
334
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
335
      const response = await fetch('/api/session', {
336
        method: 'POST',
337
        credentials: 'same-origin',
338
        headers: { 'content-type': 'application/json' },
339
        body: JSON.stringify({
340
          code,
341
          codeVerifier: transaction.verifier,
342
        }),
343
      });
344
      if (!response.ok) throw new Error('Auth0 token exchange failed');
345
      location.replace('/dashboard');
346
    };
347
    void finish().catch((reason: unknown) =>
348
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
349
    );
350
  }, []);
351
  if (error)
352
    return (
353
      <div className="loading">
354
        <span className="mark">OC</span>
355
        <p>{error}</p>
356
        <a href="/login">Return to login</a>
357
      </div>
358
    );
359
  return <Loading />;
360
}
361
362
function PageContent({
363
  page,
364
  data,
365
  settings,
366
  setSettings,
367
  navigate,
368
  refreshDashboard,
369
}: {
370
  page: Page;
371
  data: DashboardData;
372
  settings: Settings;
373
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
374
  navigate: (page: Page) => void;
375
  refreshDashboard: () => Promise<void>;
376
}) {
377
  if (page === 'history') return <History data={data} />;
378
  if (page === 'agents')
379
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} />;
380
  if (page === 'insights') return <Insights data={data} />;
381
  if (page === 'connect') return <Connect data={data} refreshDashboard={refreshDashboard} />;
382
  if (page === 'settings') return <SettingsPage settings={settings} setSettings={setSettings} />;
383
  return <Overview data={data} navigate={navigate} refreshDashboard={refreshDashboard} />;
384
}
385
386
function Overview({
387
  data,
388
  navigate,
389
  refreshDashboard,
390
}: {
391
  data: DashboardData;
392
  navigate: (page: Page) => void;
393
  refreshDashboard: () => Promise<void>;
394
}) {
395
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
396
  const warnings = data.events.filter((item) =>
397
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
398
  ).length;
399
  const publishedIds = new Set(
400
    data.publications
401
      .filter((publication) => publication.published)
402
      .map((publication) => String(publication.agentId)),
403
  );
404
  const readyAgents = data.agents.filter((agent) => publishedIds.has(String(agent.agentId))).length;
405
  const onlineAgents = data.agents.filter((agent) => agent.presence?.status === 'online').length;
406
  const pendingInvitations = data.federatedInteractions.filter(
407
    (interaction) => interaction.status === 'pending',
408
  ).length;
409
  return (
410
    <>
411
      <PageHead
412
        eyebrow="NETWORK OVERVIEW"
413
        title="Assurance, at a glance."
414
        action="Connect agent"
415
        onAction={() => navigate('connect')}
416
      />
417
      <section
418
        className={`readiness ${readyAgents === data.agents.length && readyAgents ? 'ready' : ''}`}
419
      >
420
        <div>
421
          <span className="statusOrb">{readyAgents ? '✓' : '!'}</span>
422
          <div>
423
            <strong>
424
              {readyAgents === data.agents.length && readyAgents
425
                ? 'Your agent network is ready'
426
                : `${readyAgents} of ${data.agents.length} agents can receive A2A work`}
427
            </strong>
428
            <small>
429
              Safe matching requests activate automatically. Everything else waits for approval.
430
            </small>
431
          </div>
432
        </div>
433
        <button className="secondary" onClick={() => navigate('agents')}>
434
          Manage automation
435
        </button>
436
      </section>
437
      <section className="metrics">
438
        <Metric
439
          label="Online agents"
440
          value={onlineAgents}
441
          note={`${data.agents.length - onlineAgents} offline · 2 min window`}
442
        />
443
        <Metric
444
          label="Interactions"
445
          value={data.interactions.length + data.federatedInteractions.length}
446
          note="local and shared"
447
        />
448
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
449
        <Metric
450
          label="Needs review"
451
          value={warnings + pendingInvitations}
452
          note="warnings or approvals"
453
          warn={warnings + pendingInvitations > 0}
454
        />
455
      </section>
456
      <Invitations data={data} refreshDashboard={refreshDashboard} />
457
      <section className="contentGrid">
458
        <Panel title="Recent activity" subtitle="Structured events and signed outcomes">
459
          <Timeline events={data.events.slice(-6).reverse()} />
460
          <TextButton onClick={() => navigate('history')}>View complete history →</TextButton>
461
        </Panel>
462
        <Panel
463
          title="Reliability context"
464
          subtitle="Task-specific profiles, never a universal score"
465
        >
466
          {data.profiles.length ? (
467
            data.profiles
468
              .slice(0, 4)
469
              .map((profile) => (
470
                <Profile key={`${profile.agentId}-${profile.taskCategory}`} profile={profile} />
471
              ))
472
          ) : (
473
            <Empty
474
              title="No reliability profile yet"
475
              text="Profiles appear after signed receipts and eligible bilateral feedback."
476
            />
477
          )}
478
        </Panel>
479
      </section>
480
    </>
481
  );
482
}
483
484
function History({ data }: { data: DashboardData }) {
485
  const records = useMemo(
486
    () =>
487
      [
488
        ...data.interactions.map((value) => ({ ...value, _kind: 'interaction' })),
489
        ...data.federatedInteractions.map((value) => ({
490
          ...value,
491
          _kind: 'federated interaction',
492
        })),
493
        ...data.events.map((value) => ({ ...value, _kind: 'event' })),
494
        ...data.receipts.map((value) => ({ ...value, _kind: 'receipt' })),
495
        ...data.conflicts.map((value) => ({ ...value, _kind: 'dispute' })),
496
      ].sort((a, b) => Date.parse(timestamp(b)) - Date.parse(timestamp(a))),
497
    [data],
498
  );
499
  return (
500
    <>
501
      <PageHead eyebrow="AUDIT HISTORY" title="What happened, and what proves it." />
502
      <Panel
503
        title="Account history"
504
        subtitle="Only structured metadata, signatures, hashes, and permitted evidence are hosted"
505
      >
506
        {records.length ? (
507
          records.map((item, index) => (
508
            <HistoryRow
509
              key={String(
510
                (item as Record<string, any>).eventId ??
511
                  (item as Record<string, any>).receiptId ??
512
                  (item as Record<string, any>).interactionId ??
513
                  index,
514
              )}
515
              item={item}
516
            />
517
          ))
518
        ) : (
519
          <Empty
520
            title="No interactions recorded"
521
            text="Connect an agent and start an assured interaction. Raw message bodies will not appear here."
522
          />
523
        )}
524
      </Panel>
525
    </>
526
  );
527
}
528
529
function Agents({
530
  data,
531
  navigate,
532
  refreshDashboard,
533
}: {
534
  data: DashboardData;
535
  navigate: (page: Page) => void;
536
  refreshDashboard: () => Promise<void>;
537
}) {
538
  const [working, setWorking] = useState('');
539
  const [error, setError] = useState('');
540
  const saveAutomation = async (
541
    agentId: string,
542
    value: {
543
      autoPublish: boolean;
544
      autoAcceptPolicy: 'off' | 'safe_matching';
545
      autoAcceptTaskCategories: string[];
546
    },
547
  ) => {
548
    setWorking(agentId);
549
    setError('');
550
    try {
551
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/automation`, {
552
        method: 'PUT',
553
        body: JSON.stringify(value),
554
      });
555
      await refreshDashboard();
556
    } catch (reason) {
557
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
558
    } finally {
559
      setWorking('');
560
    }
561
  };
562
  const saveRuntime = async (agentId: string, endpoint: string) => {
563
    setWorking(`runtime:${agentId}`);
564
    setError('');
565
    try {
566
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, {
567
        method: 'PUT',
568
        body: JSON.stringify({ endpoint }),
569
      });
570
      await refreshDashboard();
571
    } catch (reason) {
572
      setError(reason instanceof Error ? reason.message : 'Runtime verification failed');
573
    } finally {
574
      setWorking('');
575
    }
576
  };
577
  const disableRuntime = async (agentId: string) => {
578
    setWorking(`runtime:${agentId}`);
579
    setError('');
580
    try {
581
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/runtime`, { method: 'DELETE' });
582
      await refreshDashboard();
583
    } catch (reason) {
584
      setError(reason instanceof Error ? reason.message : 'Runtime update failed');
585
    } finally {
586
      setWorking('');
587
    }
588
  };
589
  const deleteAgent = async (agent: Record<string, any>) => {
590
    const agentId = String(agent.agentId);
591
    if (
592
      !window.confirm(
593
        `Delete “${String(agent.name ?? agentId)}”?\n\nIts runtime, publication, presence and MCP binding will be removed. Signed interaction and receipt history will be retained.`,
594
      )
595
    )
596
      return;
597
    setWorking(`delete:${agentId}`);
598
    setError('');
599
    try {
600
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}`, { method: 'DELETE' });
601
      await refreshDashboard();
602
    } catch (reason) {
603
      setError(reason instanceof Error ? reason.message : 'Agent deletion failed');
604
    } finally {
605
      setWorking('');
606
    }
607
  };
608
  return (
609
    <>
610
      <PageHead
611
        eyebrow="IDENTITY REGISTRY"
612
        title="Your connected agents."
613
        action="Connect agent"
614
        onAction={() => navigate('connect')}
615
      />
616
      <div className="notice">
617
        <strong>Direct live sessions.</strong> OpenClasp verifies both runtimes, brokers scoped
618
        credentials, then gets out of the message path. Safe matching tasks can be accepted
619
        automatically; sensitive or mismatched requests still require review.
620
      </div>
621
      {error && <div className="errorBar">{error}</div>}
622
      <section className="agentGrid">
623
        {data.agents.length ? (
624
          data.agents.map((agent) => (
625
            <AgentCard
626
              key={agent.agentId}
627
              agent={agent}
628
              projectName={
629
                data.projects.find((project) => project.projectId === agent.projectId)?.name
630
              }
631
              published={data.publications.some(
632
                (publication) => publication.agentId === agent.agentId && publication.published,
633
              )}
634
              working={working === agent.agentId}
635
              onSave={(value) => saveAutomation(agent.agentId, value)}
636
              runtime={data.runtimes.find((runtime) => runtime.agentId === agent.agentId)}
637
              runtimeWorking={working === `runtime:${agent.agentId}`}
638
              deleteWorking={working === `delete:${agent.agentId}`}
639
              onRuntime={(endpoint) => saveRuntime(agent.agentId, endpoint)}
640
              onDisableRuntime={() => disableRuntime(agent.agentId)}
641
              onDelete={() => deleteAgent(agent)}
642
            />
643
          ))
644
        ) : (
645
          <Empty
646
            title="No agents connected"
647
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
648
            action="Open connection guide"
649
            onAction={() => navigate('connect')}
650
          />
651
        )}
652
      </section>
653
    </>
654
  );
655
}
656
657
function Invitations({
658
  data,
659
  refreshDashboard,
660
}: {
661
  data: DashboardData;
662
  refreshDashboard: () => Promise<void>;
663
}) {
664
  const [working, setWorking] = useState('');
665
  const [error, setError] = useState('');
666
  const ownedAgentIds = useMemo(
667
    () => new Set(data.agents.map((agent) => String(agent.agentId))),
668
    [data.agents],
669
  );
670
  const incoming = data.federatedInteractions.filter(
671
    (interaction) =>
672
      interaction.status === 'pending' && ownedAgentIds.has(String(interaction.responderAgentId)),
673
  );
674
  const respond = async (interactionId: string, agentId: string, decision: 'accept' | 'reject') => {
675
    setWorking(interactionId);
676
    setError('');
677
    try {
678
      await api(`/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/respond`, {
679
        method: 'POST',
680
        body: JSON.stringify({ agentId, decision }),
681
      });
682
      await refreshDashboard();
683
    } catch (reason) {
684
      setError(reason instanceof Error ? reason.message : 'Could not respond to invitation');
685
    } finally {
686
      setWorking('');
687
    }
688
  };
689
  if (!incoming.length && !data.federatedInteractions.length) return null;
690
  return (
691
    <Panel
692
      title={incoming.length ? `Agent invitations (${incoming.length})` : 'Shared interactions'}
693
      subtitle="Both accounts see the same immutable contract and acceptance state"
694
    >
695
      {error ? <div className="errorBar">{error}</div> : null}
696
      {(incoming.length ? incoming : data.federatedInteractions.slice(0, 5)).map((interaction) => (
697
        <article className="setupRequest" key={interaction.interactionId}>
698
          <div>
699
            <strong>{interaction.contract?.purpose ?? interaction.interactionId}</strong>
700
            <small>
701
              {interaction.initiatorAgentId} → {interaction.responderAgentId} · {interaction.status}
702
            </small>
703
            <small>Contract: {String(interaction.termsHash).slice(0, 16)}…</small>
704
            {data.liveSessions.find(
705
              (session) => session.interactionId === interaction.interactionId,
706
            ) ? (
707
              <small className="autoAccepted">
708
                Live session:{' '}
709
                {
710
                  data.liveSessions.find(
711
                    (session) => session.interactionId === interaction.interactionId,
712
                  )?.status
713
                }
714
              </small>
715
            ) : null}
716
            {interaction.status === 'active' &&
717
            interaction.acceptances?.[interaction.responderAgentId]?.method ===
718
              'policy_auto_accept' ? (
719
              <small className="autoAccepted">✓ Auto-approved by the responder's safe policy</small>
720
            ) : null}
721
          </div>
722
          {interaction.status === 'pending' &&
723
          ownedAgentIds.has(String(interaction.responderAgentId)) ? (
724
            <div className="decisionButtons">
725
              <button
726
                className="secondary"
727
                disabled={working === interaction.interactionId}
728
                onClick={() =>
729
                  void respond(interaction.interactionId, interaction.responderAgentId, 'reject')
730
                }
731
              >
732
                Reject
733
              </button>
734
              <button
735
                className="primary"
736
                disabled={working === interaction.interactionId}
737
                onClick={() =>
738
                  void respond(interaction.interactionId, interaction.responderAgentId, 'accept')
739
                }
740
              >
741
                {working === interaction.interactionId ? 'Working…' : 'Accept contract'}
742
              </button>
743
            </div>
744
          ) : null}
745
        </article>
746
      ))}
747
    </Panel>
748
  );
749
}
750
751
function Insights({ data }: { data: DashboardData }) {
752
  return (
753
    <>
754
      <PageHead eyebrow="BEHAVIOURAL CONTEXT" title="Evidence, not reputation theatre." />
755
      <div className="notice">
756
        <strong>Context matters.</strong> OpenClasp profiles an agent by task category and version.
757
        It does not produce a single universal trust score.
758
      </div>
759
      <section className="agentGrid">
760
        {data.profiles.length ? (
761
          data.profiles.map((profile) => (
762
            <Panel
763
              key={`${profile.agentId}-${profile.taskCategory}`}
764
              title={profile.agentId}
765
              subtitle={`${profile.taskCategory} · v${profile.agentVersion}`}
766
            >
767
              <Profile profile={profile} expanded />
768
            </Panel>
769
          ))
770
        ) : (
771
          <Empty
772
            title="Not enough verified evidence"
773
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
774
          />
775
        )}
776
      </section>
777
    </>
778
  );
779
}
780
781
function Connect({
782
  data,
783
  refreshDashboard,
784
}: {
785
  data: DashboardData;
786
  refreshDashboard: () => Promise<void>;
787
}) {
788
  const endpoint = 'https://openclasp.vercel.app/mcp';
789
  const [copied, setCopied] = useState(false);
790
  const [working, setWorking] = useState('');
791
  const [decisionError, setDecisionError] = useState('');
792
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
793
  useEffect(() => {
794
    const poll = () => void refreshDashboard().catch(() => undefined);
795
    poll();
796
    const timer = window.setInterval(poll, 5000);
797
    return () => window.clearInterval(timer);
798
  }, [refreshDashboard]);
799
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
800
    setWorking(requestId);
801
    setDecisionError('');
802
    try {
803
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
804
        method: 'POST',
805
      });
806
      await refreshDashboard();
807
    } catch (reason) {
808
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
809
    } finally {
810
      setWorking('');
811
    }
812
  };
813
  return (
814
    <>
815
      <PageHead eyebrow="AGENT CONNECTION" title="Add OpenClasp to an agent." />
816
      {pending.length > 0 && (
817
        <section className="setupRequests">
818
          <div>
819
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
820
            <h2>Approve agent setup</h2>
821
            <p>
822
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
823
            </p>
824
          </div>
825
          {pending.map((request) => (
826
            <article className="setupRequest" key={request.requestId}>
827
              <div>
828
                <strong>{request.agentName ?? 'Switch installation'}</strong>
829
                <small>
830
                  {request.projectName ?? request.existingAgentId} · {request.framework}
831
                </small>
832
                {!!request.capabilities?.length && (
833
                  <div className="tags">
834
                    {request.capabilities.slice(0, 5).map((capability: string) => (
835
                      <span key={capability}>{capability}</span>
836
                    ))}
837
                  </div>
838
                )}
839
                {!!request.limitations?.length && (
840
                  <small>Limitations: {request.limitations.join(' · ')}</small>
841
                )}
842
                <div className="automationPreview">
843
                  <span>{request.autoPublish ? 'Public after approval' : 'Private'}</span>
844
                  <span>
845
                    {request.autoAcceptPolicy === 'safe_matching'
846
                      ? 'Auto-accept safe matches'
847
                      : 'Review every request'}
848
                  </span>
849
                </div>
850
              </div>
851
              <div className="decisionButtons">
852
                <button
853
                  className="secondary"
854
                  disabled={working === request.requestId}
855
                  onClick={() => void decide(request.requestId, 'reject')}
856
                >
857
                  Reject
858
                </button>
859
                <button
860
                  className="primary"
861
                  disabled={working === request.requestId}
862
                  onClick={() => void decide(request.requestId, 'approve')}
863
                >
864
                  {working === request.requestId ? 'Working…' : 'Approve & automate'}
865
                </button>
866
              </div>
867
            </article>
868
          ))}
869
          {decisionError && <div className="loginError">{decisionError}</div>}
870
        </section>
871
      )}
872
      <section className="connectLayout">
873
        <Panel title="Connect once" subtitle="OpenClasp handles the routine steps afterward">
874
          <div className="endpoint">
875
            <code>{endpoint}</code>
876
            <button
877
              onClick={() => {
878
                void navigator.clipboard.writeText(endpoint);
879
                setCopied(true);
880
              }}
881
            >
882
              {copied ? 'Copied' : 'Copy'}
883
            </button>
884
          </div>
885
          <ol>
886
            <li>Add this MCP URL and complete the OAuth sign-in.</li>
887
            <li>
888
              Tell the agent: <code>Set yourself up on OpenClasp</code>.
889
            </li>
890
            <li>
891
              Approve its identity and automation policy here once. That is the last routine manual
892
              step.
893
            </li>
894
          </ol>
895
        </Panel>
896
        <Panel
897
          title="What becomes automatic"
898
          subtitle="Safe defaults with deterministic approval boundaries"
899
        >
900
          <ul className="checkList">
901
            <li>Agent Card publication and discovery</li>
902
            <li>Contract defaults inferred from the task</li>
903
            <li>Safe matching invitations accepted immediately</li>
904
            <li>Ready-to-send A2A endpoint, headers, and metadata</li>
905
            <li>Risky requests routed to human review</li>
906
          </ul>
907
        </Panel>
908
      </section>
909
    </>
910
  );
911
}
912
913
function SettingsPage({
914
  settings,
915
  setSettings,
916
}: {
917
  settings: Settings;
918
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
919
}) {
920
  const [saving, setSaving] = useState(false);
921
  const [saved, setSaved] = useState(false);
922
  const save = useCallback(async () => {
923
    setSaving(true);
924
    setSaved(false);
925
    const updated = (await api('/v0.1/settings', {
926
      method: 'PUT',
927
      body: JSON.stringify(settings),
928
    })) as Settings;
929
    setSettings(updated);
930
    setSaving(false);
931
    setSaved(true);
932
  }, [settings, setSettings]);
933
  return (
934
    <>
935
      <PageHead eyebrow="ACCOUNT CONTROLS" title="Privacy and network settings." />
936
      <section className="settingsCard">
937
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
938
          <input
939
            value={settings.displayName}
940
            onChange={(event) =>
941
              setSettings((value) => ({ ...value, displayName: event.target.value }))
942
            }
943
            placeholder="Team or operator name"
944
          />
945
        </Setting>
946
        <Setting
947
          label="Network contribution"
948
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
949
        >
950
          <Toggle
951
            checked={settings.contributionEnabled}
952
            onChange={(checked) =>
953
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
954
            }
955
          />
956
        </Setting>
957
        <Setting
958
          label="Evidence sharing"
959
          description="Controls when permitted evidence references may be shared with interaction participants."
960
        >
961
          <select
962
            value={settings.evidenceSharing}
963
            onChange={(event) =>
964
              setSettings((value) => ({
965
                ...value,
966
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
967
              }))
968
            }
969
          >
970
            <option value="never">Never</option>
971
            <option value="ask">Ask every time</option>
972
            <option value="contract_only">When contract permits</option>
973
          </select>
974
        </Setting>
975
        <Setting
976
          label="Structured record retention"
977
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
978
        >
979
          <select
980
            value={settings.retentionDays}
981
            onChange={(event) =>
982
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
983
            }
984
          >
985
            <option value={7}>7 days</option>
986
            <option value={30}>30 days</option>
987
            <option value={90}>90 days</option>
988
            <option value={365}>1 year</option>
989
            <option value={0}>No automatic deletion</option>
990
          </select>
991
        </Setting>
992
        <Setting
993
          label="Raw agent messages"
994
          description="Travel directly between agent-owned runtimes. OpenClasp stores structured events and hashes only."
995
        >
996
          <span className="locked">NOT STORED</span>
997
        </Setting>
998
        <div className="saveRow">
999
          <button className="primary" onClick={() => void save()} disabled={saving}>
1000
            {saving ? 'Saving…' : 'Save settings'}
1001
          </button>
1002
          {saved && <span>Saved</span>}
1003
        </div>
1004
      </section>
1005
    </>
1006
  );
1007
}
1008
1009
function PageHead({
1010
  eyebrow,
1011
  title,
1012
  action,
1013
  onAction,
1014
}: {
1015
  eyebrow: string;
1016
  title: string;
1017
  action?: string;
1018
  onAction?: () => void;
1019
}) {
1020
  return (
1021
    <header className="pageHead">
1022
      <div>
1023
        <p className="eyebrow">{eyebrow}</p>
1024
        <h1>{title}</h1>
1025
      </div>
1026
      {action && (
1027
        <button className="primary" onClick={onAction}>
1028
          {action}
1029
        </button>
1030
      )}
1031
    </header>
1032
  );
1033
}
1034
function Nav({
1035
  page,
1036
  active,
1037
  onClick,
1038
  label,
1039
  glyph,
1040
}: {
1041
  page: Page;
1042
  active: Page;
1043
  onClick: (page: Page) => void;
1044
  label: string;
1045
  glyph: string;
1046
}) {
1047
  return (
1048
    <button className={active === page ? 'active' : ''} onClick={() => onClick(page)}>
1049
      <span>{glyph}</span>
1050
      {label}
1051
    </button>
1052
  );
1053
}
1054
function Metric({
1055
  label,
1056
  value,
1057
  note,
1058
  warn,
1059
}: {
1060
  label: string;
1061
  value: number;
1062
  note: string;
1063
  warn?: boolean;
1064
}) {
1065
  return (
1066
    <article className={warn ? 'metric warn' : 'metric'}>
1067
      <span>{label}</span>
1068
      <strong>{String(value).padStart(2, '0')}</strong>
1069
      <small>{note}</small>
1070
    </article>
1071
  );
1072
}
1073
function Panel({
1074
  title,
1075
  subtitle,
1076
  children,
1077
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
1078
  return (
1079
    <section className="panel">
1080
      <div className="panelHead">
1081
        <div>
1082
          <h2>{title}</h2>
1083
          <p>{subtitle}</p>
1084
        </div>
1085
      </div>
1086
      {children}
1087
    </section>
1088
  );
1089
}
1090
function Timeline({ events }: { events: Record<string, any>[] }) {
1091
  return events.length ? (
1092
    <div className="timeline">
1093
      {events.map((event) => (
1094
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
1095
      ))}
1096
    </div>
1097
  ) : (
1098
    <Empty
1099
      title="Nothing recorded yet"
1100
      text="Activity appears after your connected agents submit signed structured events."
1101
    />
1102
  );
1103
}
1104
function HistoryRow({ item }: { item: Record<string, any> }) {
1105
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
1106
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
1107
  return (
1108
    <article className="historyRow">
1109
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
1110
      <div>
1111
        <strong>{kind}</strong>
1112
        <small>
1113
          {id} · {new Date(timestamp(item)).toLocaleString()}
1114
        </small>
1115
      </div>
1116
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
1117
    </article>
1118
  );
1119
}
1120
function AgentCard({
1121
  agent,
1122
  projectName,
1123
  published,
1124
  working,
1125
  onSave,
1126
  runtime,
1127
  runtimeWorking,
1128
  deleteWorking,
1129
  onRuntime,
1130
  onDisableRuntime,
1131
  onDelete,
1132
}: {
1133
  agent: Record<string, any>;
1134
  projectName?: string;
1135
  published: boolean;
1136
  working: boolean;
1137
  onSave: (value: {
1138
    autoPublish: boolean;
1139
    autoAcceptPolicy: 'off' | 'safe_matching';
1140
    autoAcceptTaskCategories: string[];
1141
  }) => void;
1142
  runtime: Record<string, any> | undefined;
1143
  runtimeWorking: boolean;
1144
  deleteWorking: boolean;
1145
  onRuntime: (endpoint: string) => void;
1146
  onDisableRuntime: () => void;
1147
  onDelete: () => void;
1148
}) {
1149
  const [editing, setEditing] = useState(false);
1150
  const [autoPublish, setAutoPublish] = useState(Boolean(agent.autoPublish ?? published));
1151
  const [autoAcceptPolicy, setAutoAcceptPolicy] = useState<'off' | 'safe_matching'>(
1152
    agent.autoAcceptPolicy === 'safe_matching' ? 'safe_matching' : 'off',
1153
  );
1154
  const [categories, setCategories] = useState<string>(
1155
    (agent.autoAcceptTaskCategories?.length
1156
      ? agent.autoAcceptTaskCategories
1157
      : (agent.capabilities ?? [])
1158
    ).join(', '),
1159
  );
1160
  const [runtimeEndpoint, setRuntimeEndpoint] = useState(String(runtime?.endpoint ?? ''));
1161
  const ready = published;
1162
  const online = agent.presence?.status === 'online';
1163
  const endpoint = String(runtime?.a2aEndpoint ?? agent.a2aEndpoint ?? 'Connect a runtime first');
1164
  const identityLabel = agent.revoked
1165
    ? 'REVOKED'
1166
    : agent.identityMode === 'oauth_installation'
1167
      ? 'AUTHENTICATED'
1168
      : 'VERIFIED';
1169
  return (
1170
    <article className="agentCard">
1171
      <div className="agentTop">
1172
        <span className="agentGlyph">◇</span>
1173
        <div className="agentBadges">
1174
          <b className={online ? 'onlineBadge' : 'offlineBadge'}>
1175
            ● {online ? 'ONLINE' : 'OFFLINE'}
1176
          </b>
1177
          <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
1178
          <b className={ready ? 'readyBadge' : 'needsBadge'}>{ready ? 'READY' : 'SETUP NEEDED'}</b>
1179
        </div>
1180
      </div>
1181
      <h2>{agent.name ?? agent.agentId}</h2>
1182
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
1183
      <div className="tags">
1184
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
1185
          <span key={capability}>{capability}</span>
1186
        ))}
1187
      </div>
1188
      <small>
1189
        {agent.framework ? `${agent.framework} · ` : ''}
1190
        {agent.identityMode === 'oauth_installation' ? 'OAuth-bound · ' : 'Ed25519 · '}Created{' '}
1191
        {new Date(agent.createdAt).toLocaleDateString()}
1192
      </small>
1193
      <small>
1194
        {agent.presence?.lastSeenAt
1195
          ? `Last agent activity ${new Date(agent.presence.lastSeenAt).toLocaleString()}`
1196
          : 'No agent activity recorded yet'}
1197
      </small>
1198
      {published ? (
1199
        <a
1200
          href={`/agents/${encodeURIComponent(agent.agentId)}/card.json`}
1201
          target="_blank"
1202
          rel="noreferrer"
1203
        >
1204
          Public Agent Card ↗
1205
        </a>
1206
      ) : null}
1207
      <div className="automationSummary">
1208
        <span>{published ? '● Public discovery' : '○ Private'}</span>
1209
        <span>↗ Direct agent-owned A2A</span>
1210
        <span>
1211
          {agent.autoAcceptPolicy === 'safe_matching'
1212
            ? '⚡ Safe tasks automatic'
1213
            : '◷ Manual approval'}
1214
        </span>
1215
      </div>
1216
      <div className="runtimeBox">
1217
        <div>
1218
          <strong>Autonomous runtime</strong>
1219
          <span className={runtime?.status === 'verified' ? 'runtimeLive' : 'runtimeMissing'}>
1220
            {runtime?.status === 'verified' ? 'VERIFIED' : 'NOT CONNECTED'}
1221
          </span>
1222
        </div>
1223
        <p>
1224
          Connect the worker running on your cloud. OpenClasp uses this callback only to prepare a
1225
          live session; messages then travel directly between agents.
1226
        </p>
1227
        <input
1228
          type="url"
1229
          value={runtimeEndpoint}
1230
          onChange={(event) => setRuntimeEndpoint(event.target.value)}
1231
          placeholder="https://agent.example.com/openclasp"
1232
        />
1233
        <div className="agentActions">
1234
          {runtime?.status === 'verified' ? (
1235
            <button className="secondary" disabled={runtimeWorking} onClick={onDisableRuntime}>
1236
              Disable runtime
1237
            </button>
1238
          ) : null}
1239
          <button
1240
            className="primary"
1241
            disabled={runtimeWorking || !runtimeEndpoint.trim()}
1242
            onClick={() => onRuntime(runtimeEndpoint.trim())}
1243
          >
1244
            {runtimeWorking ? 'Verifying…' : runtime ? 'Rotate connection' : 'Verify & connect'}
1245
          </button>
1246
        </div>
1247
        {runtime?.lastError ? <small>Last session error: {runtime.lastError}</small> : null}
1248
      </div>
1249
      {editing ? (
1250
        <div className="automationForm">
1251
          <label>
1252
            <span>Agent-owned A2A endpoint</span>
1253
            <input type="url" value={endpoint} readOnly />
1254
          </label>
1255
          <label>
1256
            <span>Invitation policy</span>
1257
            <select
1258
              value={autoAcceptPolicy}
1259
              onChange={(event) =>
1260
                setAutoAcceptPolicy(event.target.value as 'off' | 'safe_matching')
1261
              }
1262
            >
1263
              <option value="safe_matching">Auto-accept safe matches</option>
1264
              <option value="off">Review every request</option>
1265
            </select>
1266
          </label>
1267
          <label>
1268
            <span>Safe task categories</span>
1269
            <input
1270
              value={categories}
1271
              onChange={(event) => setCategories(event.target.value)}
1272
              placeholder="research, planning, coding"
1273
            />
1274
          </label>
1275
          <label className="checkControl">
1276
            <input
1277
              type="checkbox"
1278
              checked={autoPublish}
1279
              onChange={(event) => setAutoPublish(event.target.checked)}
1280
            />
1281
            <span>Publish and keep Agent Card updated</span>
1282
          </label>
1283
          <div className="agentActions">
1284
            <button className="secondary" onClick={() => setEditing(false)}>
1285
              Cancel
1286
            </button>
1287
            <button
1288
              className="primary"
1289
              disabled={working || agent.status === 'revoked'}
1290
              onClick={() =>
1291
                onSave({
1292
                  autoPublish,
1293
                  autoAcceptPolicy,
1294
                  autoAcceptTaskCategories: categories
1295
                    .split(',')
1296
                    .map((value) => value.trim())
1297
                    .filter(Boolean),
1298
                })
1299
              }
1300
            >
1301
              {working ? 'Saving…' : 'Save automation'}
1302
            </button>
1303
          </div>
1304
        </div>
1305
      ) : (
1306
        <div className="agentActions agentManagementActions">
1307
          <button
1308
            className="dangerButton"
1309
            disabled={working || runtimeWorking || deleteWorking}
1310
            onClick={onDelete}
1311
          >
1312
            {deleteWorking ? 'Deleting…' : 'Delete agent'}
1313
          </button>
1314
          <button
1315
            className="secondary"
1316
            disabled={working || runtimeWorking || deleteWorking || agent.status === 'revoked'}
1317
            onClick={() => setEditing(true)}
1318
          >
1319
            Configure automation
1320
          </button>
1321
        </div>
1322
      )}
1323
    </article>
1324
  );
1325
}
1326
function Profile({ profile, expanded }: { profile: Record<string, any>; expanded?: boolean }) {
1327
  const dimensions = [
1328
    ['Completion', profile.completion],
1329
    ['Scope', profile.scope],
1330
    ['Evidence', profile.evidence],
1331
    ...(expanded
1332
      ? [
1333
          ['Communication', profile.communication],
1334
          ['Deadline', profile.deadline],
1335
        ]
1336
      : []),
1337
  ] as [string, number][];
1338
  return (
1339
    <article className="profile">
1340
      <div>
1341
        <strong>{profile.agentId}</strong>
1342
        <small>
1343
          {profile.taskCategory} · {profile.sampleSize} outcome(s)
1344
        </small>
1345
      </div>
1346
      {dimensions.map(([label, value]) => (
1347
        <Meter key={label} label={label} value={value ?? 0} />
1348
      ))}
1349
    </article>
1350
  );
1351
}
1352
function Meter({ label, value }: { label: string; value: number }) {
1353
  return (
1354
    <div className="meter">
1355
      <span>{label}</span>
1356
      <i>
1357
        <b style={{ width: `${Math.round(value * 100)}%` }} />
1358
      </i>
1359
      <strong>{Math.round(value * 100)}%</strong>
1360
    </div>
1361
  );
1362
}
1363
function Empty({
1364
  title,
1365
  text,
1366
  action,
1367
  onAction,
1368
}: {
1369
  title: string;
1370
  text: string;
1371
  action?: string;
1372
  onAction?: () => void;
1373
}) {
1374
  return (
1375
    <div className="empty">
1376
      <span>◇</span>
1377
      <strong>{title}</strong>
1378
      <p>{text}</p>
1379
      {action && (
1380
        <button className="secondary" onClick={onAction}>
1381
          {action}
1382
        </button>
1383
      )}
1384
    </div>
1385
  );
1386
}
1387
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
1388
  return (
1389
    <button className="textButton" onClick={onClick}>
1390
      {children}
1391
    </button>
1392
  );
1393
}
1394
function Setting({
1395
  label,
1396
  description,
1397
  children,
1398
}: React.PropsWithChildren<{ label: string; description: string }>) {
1399
  return (
1400
    <div className="setting">
1401
      <div>
1402
        <strong>{label}</strong>
1403
        <p>{description}</p>
1404
      </div>
1405
      <div>{children}</div>
1406
    </div>
1407
  );
1408
}
1409
function Toggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
1410
  return (
1411
    <button
1412
      className={checked ? 'toggle on' : 'toggle'}
1413
      role="switch"
1414
      aria-checked={checked}
1415
      onClick={() => onChange(!checked)}
1416
    >
1417
      <span />
1418
    </button>
1419
  );
1420
}
1421
function Loading({ compact }: { compact?: boolean }) {
1422
  return (
1423
    <div className={compact ? 'loading compact' : 'loading'}>
1424
      <span className="mark">OC</span>
1425
      <p>Verifying session…</p>
1426
    </div>
1427
  );
1428
}
1429
function initials(value: string) {
1430
  return value
1431
    .split(/\s+|@/)
1432
    .slice(0, 2)
1433
    .map((part) => part[0]?.toUpperCase())
1434
    .join('');
1435
}
1436
function timestamp(item: Record<string, any>) {
1437
  return String(item.timestamp ?? item.completedAt ?? item.createdAt ?? new Date(0).toISOString());
1438
}
1439
1440
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
1441
  throw new Error('Auth0 public configuration is incomplete');
1442
createRoot(document.getElementById('root')!).render(
1443
  <React.StrictMode>
1444
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
1445
  </React.StrictMode>,
1446
);