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

Viewing files at commit 4bfaf3cca6a5Commit detailsView latest

apps/dashboard/src/main.tsx 1051 lines · 33.5 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
  events: Record<string, any>[];
25
  conflicts: Record<string, any>[];
26
  receipts: Record<string, any>[];
27
  profiles: Record<string, any>[];
28
};
29
30
type Settings = {
31
  displayName: string;
32
  contributionEnabled: boolean;
33
  retentionDays: number;
34
  evidenceSharing: 'never' | 'ask' | 'contract_only';
35
  rawConversationsStored: false;
36
};
37
38
const emptyData: DashboardData = {
39
  agents: [],
40
  projects: [],
41
  installations: [],
42
  setupRequests: [],
43
  publications: [],
44
  interactions: [],
45
  events: [],
46
  conflicts: [],
47
  receipts: [],
48
  profiles: [],
49
};
50
const defaultSettings: Settings = {
51
  displayName: '',
52
  contributionEnabled: false,
53
  retentionDays: 30,
54
  evidenceSharing: 'ask',
55
  rawConversationsStored: false,
56
};
57
const pages = ['dashboard', 'history', 'agents', 'insights', 'connect', 'settings'] as const;
58
type Page = (typeof pages)[number];
59
60
function base64Url(bytes: Uint8Array) {
61
  let binary = '';
62
  for (const byte of bytes) binary += String.fromCharCode(byte);
63
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
64
}
65
66
function randomValue(size = 32) {
67
  return base64Url(crypto.getRandomValues(new Uint8Array(size)));
68
}
69
70
async function beginAuth(provider: 'google' | 'github') {
71
  const verifier = randomValue(48);
72
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
73
  const transaction: AuthTransaction = { state: randomValue(), nonce: randomValue(), verifier };
74
  sessionStorage.setItem(authTransactionKey, JSON.stringify(transaction));
75
  const parameters = new URLSearchParams({
76
    client_id: __AUTH0_CLIENT_ID__,
77
    redirect_uri: `${location.origin}/sso-callback`,
78
    response_type: 'code',
79
    scope: 'openid profile email',
80
    audience: __AUTH0_AUDIENCE__,
81
    state: transaction.state,
82
    nonce: transaction.nonce,
83
    code_challenge: base64Url(new Uint8Array(digest)),
84
    code_challenge_method: 'S256',
85
    connection: provider === 'google' ? 'google-oauth2' : 'github',
86
  });
87
  location.assign(`https://${__AUTH0_DOMAIN__}/authorize?${parameters}`);
88
}
89
90
async function signOut() {
91
  await fetch('/api/session', { method: 'DELETE', credentials: 'same-origin' });
92
  const parameters = new URLSearchParams({
93
    client_id: __AUTH0_CLIENT_ID__,
94
    returnTo: `${location.origin}/login`,
95
  });
96
  location.assign(`https://${__AUTH0_DOMAIN__}/v2/logout?${parameters}`);
97
}
98
99
async function api(path: string, init?: RequestInit) {
100
  const headers = new Headers(init?.headers);
101
  if (init?.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
102
  return fetch(path, {
103
    ...init,
104
    credentials: 'same-origin',
105
    headers,
106
  }).then(async (response) => {
107
    if (!response.ok)
108
      throw new Error((await response.text()) || `Request failed: ${response.status}`);
109
    return response.json();
110
  });
111
}
112
113
function route(): Page {
114
  const value = location.pathname.slice(1) || 'dashboard';
115
  return pages.includes(value as Page) ? (value as Page) : 'dashboard';
116
}
117
118
function initialTheme(): Theme {
119
  const saved = localStorage.getItem(themeKey);
120
  if (saved === 'dark' || saved === 'light') return saved;
121
  return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
122
}
123
124
function App() {
125
  const [session, setSession] = useState<AuthSession | null>();
126
  const [theme, setTheme] = useState<Theme>(initialTheme);
127
  const [page, setPage] = useState<Page>(route());
128
  const [data, setData] = useState<DashboardData>(emptyData);
129
  const [settings, setSettings] = useState<Settings>(defaultSettings);
130
  const [loading, setLoading] = useState(true);
131
  const [error, setError] = useState('');
132
  const refreshDashboard = useCallback(async () => {
133
    setData((await api('/v0.1/dashboard')) as DashboardData);
134
  }, []);
135
136
  useEffect(() => {
137
    fetch('/api/session', { credentials: 'same-origin' })
138
      .then(async (response) => {
139
        if (!response.ok) return null;
140
        return (await response.json()) as AuthSession;
141
      })
142
      .then(setSession)
143
      .catch(() => setSession(null));
144
  }, []);
145
146
  useEffect(() => {
147
    document.documentElement.dataset.theme = theme;
148
    localStorage.setItem(themeKey, theme);
149
  }, [theme]);
150
151
  useEffect(() => {
152
    const onPopState = () => setPage(route());
153
    addEventListener('popstate', onPopState);
154
    return () => removeEventListener('popstate', onPopState);
155
  }, []);
156
157
  useEffect(() => {
158
    if (session === undefined) return;
159
    if (!session && location.pathname !== '/login') history.replaceState({}, '', '/login');
160
    if (!session) return;
161
    if (location.pathname === '/login') history.replaceState({}, '', '/dashboard');
162
    setPage(route());
163
    Promise.all([api('/v0.1/dashboard'), api('/v0.1/settings')])
164
      .then(([dashboard, accountSettings]) => {
165
        setData(dashboard as DashboardData);
166
        setSettings(accountSettings as Settings);
167
      })
168
      .catch((reason: unknown) =>
169
        setError(reason instanceof Error ? reason.message : 'Load failed'),
170
      )
171
      .finally(() => setLoading(false));
172
  }, [session]);
173
174
  const navigate = (next: Page) => {
175
    history.pushState({}, '', `/${next}`);
176
    setPage(next);
177
  };
178
179
  if (session === undefined) return <Loading />;
180
  if (!session) return <Login />;
181
182
  return (
183
    <div className="appShell">
184
      <aside>
185
        <div className="brand">
186
          <div className="mark">OC</div>
187
          <div>
188
            <strong>OpenClasp</strong>
189
            <small>ASSURANCE NETWORK</small>
190
          </div>
191
        </div>
192
        <nav>
193
          <Nav page="dashboard" active={page} onClick={navigate} label="Overview" glyph="⌂" />
194
          <Nav page="history" active={page} onClick={navigate} label="History" glyph="≡" />
195
          <Nav page="agents" active={page} onClick={navigate} label="Agents" glyph="◇" />
196
          <Nav page="insights" active={page} onClick={navigate} label="Insights" glyph="⌁" />
197
          <Nav page="connect" active={page} onClick={navigate} label="Connect" glyph="+" />
198
          <Nav page="settings" active={page} onClick={navigate} label="Settings" glyph="⚙" />
199
        </nav>
200
        <button
201
          className="themeSwitch"
202
          onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
203
          aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
204
        >
205
          <span>{theme === 'dark' ? '☼' : '◐'}</span>
206
          {theme === 'dark' ? 'Light theme' : 'Dark theme'}
207
        </button>
208
        <div className="privacyStamp">
209
          <span className="liveDot" />
210
          <div>
211
            <strong>Structured-only</strong>
212
            <small>Raw conversations stay local</small>
213
          </div>
214
        </div>
215
        <button className="account" onClick={() => void signOut()}>
216
          <span>{initials(session.user.name || session.user.email || 'OC')}</span>
217
          <div>
218
            <strong>{session.user.name || 'OpenClasp user'}</strong>
219
            <small>{session.user.email || 'Sign out'}</small>
220
          </div>
221
          <b>↗</b>
222
        </button>
223
      </aside>
224
      <main>
225
        {error && <div className="errorBar">{error}</div>}
226
        {loading ? (
227
          <Loading compact />
228
        ) : (
229
          <PageContent
230
            page={page}
231
            data={data}
232
            settings={settings}
233
            setSettings={setSettings}
234
            navigate={navigate}
235
            refreshDashboard={refreshDashboard}
236
          />
237
        )}
238
      </main>
239
    </div>
240
  );
241
}
242
243
function Login() {
244
  const [error, setError] = useState('');
245
  const continueWith = async (provider: 'google' | 'github') => {
246
    setError('');
247
    try {
248
      await beginAuth(provider);
249
    } catch {
250
      setError(`${provider === 'google' ? 'Google' : 'GitHub'} sign-in is not configured yet.`);
251
    }
252
  };
253
  return (
254
    <div className="loginPage">
255
      <section className="loginPitch">
256
        <div className="brand">
257
          <div className="mark">OC</div>
258
          <strong>OpenClasp</strong>
259
        </div>
260
        <div>
261
          <p className="eyebrow">AGENT-TO-AGENT ASSURANCE</p>
262
          <h1>Know who your agent is dealing with.</h1>
263
          <p>
264
            Verified identities, signed outcomes, private warnings, and contextual
265
            reliability—without collecting raw conversations.
266
          </p>
267
        </div>
268
        <div className="loginProof">
269
          <span>01</span> User-owned history<span>02</span> No universal trust score<span>03</span>{' '}
270
          Explicit network consent
271
        </div>
272
      </section>
273
      <section className="loginCard">
274
        <div>
275
          <p className="eyebrow">SECURE ACCESS</p>
276
          <h2>Sign in to OpenClasp</h2>
277
          <p>
278
            Manage connected agents, review signed history, and control what contributes to the
279
            network.
280
          </p>
281
        </div>
282
        <div className="socialButtons">
283
          <button onClick={() => void continueWith('google')}>
284
            <span className="google">G</span> Continue with Google
285
          </button>
286
          <button onClick={() => void continueWith('github')}>
287
            <span>◉</span> Continue with GitHub
288
          </button>
289
        </div>
290
        {error && <div className="loginError">{error}</div>}
291
        <small>
292
          Google and GitHub handle authentication. OpenClasp never receives your password.
293
        </small>
294
      </section>
295
    </div>
296
  );
297
}
298
299
let callbackStarted = false;
300
function AuthCallback() {
301
  const [error, setError] = useState('');
302
  useEffect(() => {
303
    if (callbackStarted) return;
304
    callbackStarted = true;
305
    const finish = async () => {
306
      const query = new URLSearchParams(location.search);
307
      const oauthError = query.get('error_description') ?? query.get('error');
308
      if (oauthError) throw new Error(oauthError);
309
      const code = query.get('code');
310
      const returnedState = query.get('state');
311
      const rawTransaction = sessionStorage.getItem(authTransactionKey);
312
      if (!code || !returnedState || !rawTransaction) throw new Error('Missing OAuth transaction');
313
      const transaction = JSON.parse(rawTransaction) as AuthTransaction;
314
      sessionStorage.removeItem(authTransactionKey);
315
      if (returnedState !== transaction.state) throw new Error('OAuth state mismatch');
316
      const response = await fetch('/api/session', {
317
        method: 'POST',
318
        credentials: 'same-origin',
319
        headers: { 'content-type': 'application/json' },
320
        body: JSON.stringify({
321
          code,
322
          codeVerifier: transaction.verifier,
323
        }),
324
      });
325
      if (!response.ok) throw new Error('Auth0 token exchange failed');
326
      location.replace('/dashboard');
327
    };
328
    void finish().catch((reason: unknown) =>
329
      setError(reason instanceof Error ? reason.message : 'Authentication failed'),
330
    );
331
  }, []);
332
  if (error)
333
    return (
334
      <div className="loading">
335
        <span className="mark">OC</span>
336
        <p>{error}</p>
337
        <a href="/login">Return to login</a>
338
      </div>
339
    );
340
  return <Loading />;
341
}
342
343
function PageContent({
344
  page,
345
  data,
346
  settings,
347
  setSettings,
348
  navigate,
349
  refreshDashboard,
350
}: {
351
  page: Page;
352
  data: DashboardData;
353
  settings: Settings;
354
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
355
  navigate: (page: Page) => void;
356
  refreshDashboard: () => Promise<void>;
357
}) {
358
  if (page === 'history') return <History data={data} />;
359
  if (page === 'agents')
360
    return <Agents data={data} navigate={navigate} refreshDashboard={refreshDashboard} />;
361
  if (page === 'insights') return <Insights data={data} />;
362
  if (page === 'connect') return <Connect data={data} refreshDashboard={refreshDashboard} />;
363
  if (page === 'settings') return <SettingsPage settings={settings} setSettings={setSettings} />;
364
  return <Overview data={data} navigate={navigate} />;
365
}
366
367
function Overview({ data, navigate }: { data: DashboardData; navigate: (page: Page) => void }) {
368
  const completed = data.receipts.filter((item) => item.outcome === 'success').length;
369
  const warnings = data.events.filter((item) =>
370
    ['policy_warning', 'policy_violation', 'objection'].includes(String(item.eventType)),
371
  ).length;
372
  return (
373
    <>
374
      <PageHead
375
        eyebrow="NETWORK OVERVIEW"
376
        title="Assurance, at a glance."
377
        action="Connect agent"
378
        onAction={() => navigate('connect')}
379
      />
380
      <section className="metrics">
381
        <Metric label="Connected agents" value={data.agents.length} note="bound identities" />
382
        <Metric label="Interactions" value={data.interactions.length} note="signed or active" />
383
        <Metric label="Successful outcomes" value={completed} note="receipt-backed" />
384
        <Metric label="Open warnings" value={warnings} note="needs attention" warn={warnings > 0} />
385
      </section>
386
      <section className="contentGrid">
387
        <Panel title="Recent activity" subtitle="Structured events and signed outcomes">
388
          <Timeline events={data.events.slice(-6).reverse()} />
389
          <TextButton onClick={() => navigate('history')}>View complete history →</TextButton>
390
        </Panel>
391
        <Panel
392
          title="Reliability context"
393
          subtitle="Task-specific profiles, never a universal score"
394
        >
395
          {data.profiles.length ? (
396
            data.profiles
397
              .slice(0, 4)
398
              .map((profile) => (
399
                <Profile key={`${profile.agentId}-${profile.taskCategory}`} profile={profile} />
400
              ))
401
          ) : (
402
            <Empty
403
              title="No reliability profile yet"
404
              text="Profiles appear after signed receipts and eligible bilateral feedback."
405
            />
406
          )}
407
        </Panel>
408
      </section>
409
    </>
410
  );
411
}
412
413
function History({ data }: { data: DashboardData }) {
414
  const records = useMemo(
415
    () =>
416
      [
417
        ...data.interactions.map((value) => ({ ...value, _kind: 'interaction' })),
418
        ...data.events.map((value) => ({ ...value, _kind: 'event' })),
419
        ...data.receipts.map((value) => ({ ...value, _kind: 'receipt' })),
420
        ...data.conflicts.map((value) => ({ ...value, _kind: 'dispute' })),
421
      ].sort((a, b) => Date.parse(timestamp(b)) - Date.parse(timestamp(a))),
422
    [data],
423
  );
424
  return (
425
    <>
426
      <PageHead eyebrow="AUDIT HISTORY" title="What happened, and what proves it." />
427
      <Panel
428
        title="Account history"
429
        subtitle="Only structured metadata, signatures, hashes, and permitted evidence are hosted"
430
      >
431
        {records.length ? (
432
          records.map((item, index) => (
433
            <HistoryRow
434
              key={String(
435
                (item as Record<string, any>).eventId ??
436
                  (item as Record<string, any>).receiptId ??
437
                  (item as Record<string, any>).interactionId ??
438
                  index,
439
              )}
440
              item={item}
441
            />
442
          ))
443
        ) : (
444
          <Empty
445
            title="No interactions recorded"
446
            text="Connect an agent and start an assured interaction. Raw message bodies will not appear here."
447
          />
448
        )}
449
      </Panel>
450
    </>
451
  );
452
}
453
454
function Agents({
455
  data,
456
  navigate,
457
  refreshDashboard,
458
}: {
459
  data: DashboardData;
460
  navigate: (page: Page) => void;
461
  refreshDashboard: () => Promise<void>;
462
}) {
463
  const [working, setWorking] = useState('');
464
  const [error, setError] = useState('');
465
  const setPublication = async (agentId: string, published: boolean) => {
466
    setWorking(agentId);
467
    setError('');
468
    try {
469
      await api(`/v0.1/agents/${encodeURIComponent(agentId)}/publication`, {
470
        method: 'POST',
471
        body: JSON.stringify({ published }),
472
      });
473
      await refreshDashboard();
474
    } catch (reason) {
475
      setError(reason instanceof Error ? reason.message : 'Directory update failed');
476
    } finally {
477
      setWorking('');
478
    }
479
  };
480
  return (
481
    <>
482
      <PageHead
483
        eyebrow="IDENTITY REGISTRY"
484
        title="Your connected agents."
485
        action="Connect agent"
486
        onAction={() => navigate('connect')}
487
      />
488
      <div className="notice">
489
        <strong>Private by default.</strong> Publishing shares only this agent's name, framework,
490
        capabilities, limitations, and assurance method. It never shares your identity, project,
491
        scores, or conversations.
492
      </div>
493
      {error && <div className="errorBar">{error}</div>}
494
      <section className="agentGrid">
495
        {data.agents.length ? (
496
          data.agents.map((agent) => (
497
            <AgentCard
498
              key={agent.agentId}
499
              agent={agent}
500
              projectName={
501
                data.projects.find((project) => project.projectId === agent.projectId)?.name
502
              }
503
              published={data.publications.some(
504
                (publication) => publication.agentId === agent.agentId && publication.published,
505
              )}
506
              working={working === agent.agentId}
507
              onPublication={(published) => setPublication(agent.agentId, published)}
508
            />
509
          ))
510
        ) : (
511
          <Empty
512
            title="No agents connected"
513
            text="Install the remote MCP endpoint in an agent, sign in, then create and register its identity."
514
            action="Open connection guide"
515
            onAction={() => navigate('connect')}
516
          />
517
        )}
518
      </section>
519
    </>
520
  );
521
}
522
523
function Insights({ data }: { data: DashboardData }) {
524
  return (
525
    <>
526
      <PageHead eyebrow="BEHAVIOURAL CONTEXT" title="Evidence, not reputation theatre." />
527
      <div className="notice">
528
        <strong>Context matters.</strong> OpenClasp profiles an agent by task category and version.
529
        It does not produce a single universal trust score.
530
      </div>
531
      <section className="agentGrid">
532
        {data.profiles.length ? (
533
          data.profiles.map((profile) => (
534
            <Panel
535
              key={`${profile.agentId}-${profile.taskCategory}`}
536
              title={profile.agentId}
537
              subtitle={`${profile.taskCategory} · v${profile.agentVersion}`}
538
            >
539
              <Profile profile={profile} expanded />
540
            </Panel>
541
          ))
542
        ) : (
543
          <Empty
544
            title="Not enough verified evidence"
545
            text="Insights grow only from signed outcomes, eligible feedback, corrections, disputes, and version-aware history."
546
          />
547
        )}
548
      </section>
549
    </>
550
  );
551
}
552
553
function Connect({
554
  data,
555
  refreshDashboard,
556
}: {
557
  data: DashboardData;
558
  refreshDashboard: () => Promise<void>;
559
}) {
560
  const endpoint = 'https://openclasp.vercel.app/mcp';
561
  const [copied, setCopied] = useState(false);
562
  const [working, setWorking] = useState('');
563
  const [decisionError, setDecisionError] = useState('');
564
  const pending = data.setupRequests.filter((request) => request.status === 'pending');
565
  useEffect(() => {
566
    const poll = () => void refreshDashboard().catch(() => undefined);
567
    poll();
568
    const timer = window.setInterval(poll, 5000);
569
    return () => window.clearInterval(timer);
570
  }, [refreshDashboard]);
571
  const decide = async (requestId: string, decision: 'approve' | 'reject') => {
572
    setWorking(requestId);
573
    setDecisionError('');
574
    try {
575
      await api(`/v0.1/onboarding/${encodeURIComponent(requestId)}/${decision}`, {
576
        method: 'POST',
577
      });
578
      await refreshDashboard();
579
    } catch (reason) {
580
      setDecisionError(reason instanceof Error ? reason.message : 'Could not update setup request');
581
    } finally {
582
      setWorking('');
583
    }
584
  };
585
  return (
586
    <>
587
      <PageHead eyebrow="AGENT CONNECTION" title="Add OpenClasp to an agent." />
588
      {pending.length > 0 && (
589
        <section className="setupRequests">
590
          <div>
591
            <p className="eyebrow">CONFIRMATION REQUIRED</p>
592
            <h2>Approve agent setup</h2>
593
            <p>
594
              An agent proposed this identity. Confirm it before OpenClasp binds the installation.
595
            </p>
596
          </div>
597
          {pending.map((request) => (
598
            <article className="setupRequest" key={request.requestId}>
599
              <div>
600
                <strong>{request.agentName ?? 'Switch installation'}</strong>
601
                <small>
602
                  {request.projectName ?? request.existingAgentId} · {request.framework}
603
                </small>
604
                {!!request.capabilities?.length && (
605
                  <div className="tags">
606
                    {request.capabilities.slice(0, 5).map((capability: string) => (
607
                      <span key={capability}>{capability}</span>
608
                    ))}
609
                  </div>
610
                )}
611
                {!!request.limitations?.length && (
612
                  <small>Limitations: {request.limitations.join(' · ')}</small>
613
                )}
614
              </div>
615
              <div className="decisionButtons">
616
                <button
617
                  className="secondary"
618
                  disabled={working === request.requestId}
619
                  onClick={() => void decide(request.requestId, 'reject')}
620
                >
621
                  Reject
622
                </button>
623
                <button
624
                  className="primary"
625
                  disabled={working === request.requestId}
626
                  onClick={() => void decide(request.requestId, 'approve')}
627
                >
628
                  {working === request.requestId ? 'Working…' : 'Confirm agent'}
629
                </button>
630
              </div>
631
            </article>
632
          ))}
633
          {decisionError && <div className="loginError">{decisionError}</div>}
634
        </section>
635
      )}
636
      <section className="connectLayout">
637
        <Panel title="Remote MCP endpoint" subtitle="For OAuth-capable MCP clients">
638
          <div className="endpoint">
639
            <code>{endpoint}</code>
640
            <button
641
              onClick={() => {
642
                void navigator.clipboard.writeText(endpoint);
643
                setCopied(true);
644
              }}
645
            >
646
              {copied ? 'Copied' : 'Copy'}
647
            </button>
648
          </div>
649
          <ol>
650
            <li>Add the URL as a remote MCP server in your agent or framework.</li>
651
            <li>The client discovers OAuth and opens the Auth0 login page.</li>
652
            <li>Sign in with this account and approve access.</li>
653
            <li>
654
              Ask the agent to call <code>openclasp_setup</code>.
655
            </li>
656
            <li>
657
              Confirm the proposed identity on this page. The installation stays bound afterward.
658
            </li>
659
          </ol>
660
        </Panel>
661
        <Panel
662
          title="What the agent receives"
663
          subtitle="Assurance tools, not a replacement transport"
664
        >
665
          <ul className="checkList">
666
            <li>Identity and delegation verification</li>
667
            <li>Counterparty-specific reliability clues</li>
668
            <li>Signed contracts, events, feedback, and receipts</li>
669
            <li>Policy challenges and consented mediation</li>
670
            <li>No hidden rewriting or raw-message upload</li>
671
          </ul>
672
        </Panel>
673
      </section>
674
    </>
675
  );
676
}
677
678
function SettingsPage({
679
  settings,
680
  setSettings,
681
}: {
682
  settings: Settings;
683
  setSettings: React.Dispatch<React.SetStateAction<Settings>>;
684
}) {
685
  const [saving, setSaving] = useState(false);
686
  const [saved, setSaved] = useState(false);
687
  const save = useCallback(async () => {
688
    setSaving(true);
689
    setSaved(false);
690
    const updated = (await api('/v0.1/settings', {
691
      method: 'PUT',
692
      body: JSON.stringify(settings),
693
    })) as Settings;
694
    setSettings(updated);
695
    setSaving(false);
696
    setSaved(true);
697
  }, [settings, setSettings]);
698
  return (
699
    <>
700
      <PageHead eyebrow="ACCOUNT CONTROLS" title="Privacy and network settings." />
701
      <section className="settingsCard">
702
        <Setting label="Display name" description="Shown only inside your OpenClasp account.">
703
          <input
704
            value={settings.displayName}
705
            onChange={(event) =>
706
              setSettings((value) => ({ ...value, displayName: event.target.value }))
707
            }
708
            placeholder="Team or operator name"
709
          />
710
        </Setting>
711
        <Setting
712
          label="Network contribution"
713
          description="Allow eligible structured aggregates to improve contextual intelligence. Raw conversations are never included."
714
        >
715
          <Toggle
716
            checked={settings.contributionEnabled}
717
            onChange={(checked) =>
718
              setSettings((value) => ({ ...value, contributionEnabled: checked }))
719
            }
720
          />
721
        </Setting>
722
        <Setting
723
          label="Evidence sharing"
724
          description="Controls when permitted evidence references may be shared with interaction participants."
725
        >
726
          <select
727
            value={settings.evidenceSharing}
728
            onChange={(event) =>
729
              setSettings((value) => ({
730
                ...value,
731
                evidenceSharing: event.target.value as Settings['evidenceSharing'],
732
              }))
733
            }
734
          >
735
            <option value="never">Never</option>
736
            <option value="ask">Ask every time</option>
737
            <option value="contract_only">When contract permits</option>
738
          </select>
739
        </Setting>
740
        <Setting
741
          label="Structured record retention"
742
          description="Retention for hosted account records. Signed protocol records may require separate revocation markers."
743
        >
744
          <select
745
            value={settings.retentionDays}
746
            onChange={(event) =>
747
              setSettings((value) => ({ ...value, retentionDays: Number(event.target.value) }))
748
            }
749
          >
750
            <option value={7}>7 days</option>
751
            <option value={30}>30 days</option>
752
            <option value={90}>90 days</option>
753
            <option value={365}>1 year</option>
754
            <option value={0}>No automatic deletion</option>
755
          </select>
756
        </Setting>
757
        <Setting
758
          label="Raw conversations"
759
          description="Message bodies remain local and user-owned. This cannot be enabled on the hosted service."
760
        >
761
          <span className="locked">ALWAYS OFF</span>
762
        </Setting>
763
        <div className="saveRow">
764
          <button className="primary" onClick={() => void save()} disabled={saving}>
765
            {saving ? 'Saving…' : 'Save settings'}
766
          </button>
767
          {saved && <span>Saved</span>}
768
        </div>
769
      </section>
770
    </>
771
  );
772
}
773
774
function PageHead({
775
  eyebrow,
776
  title,
777
  action,
778
  onAction,
779
}: {
780
  eyebrow: string;
781
  title: string;
782
  action?: string;
783
  onAction?: () => void;
784
}) {
785
  return (
786
    <header className="pageHead">
787
      <div>
788
        <p className="eyebrow">{eyebrow}</p>
789
        <h1>{title}</h1>
790
      </div>
791
      {action && (
792
        <button className="primary" onClick={onAction}>
793
          {action}
794
        </button>
795
      )}
796
    </header>
797
  );
798
}
799
function Nav({
800
  page,
801
  active,
802
  onClick,
803
  label,
804
  glyph,
805
}: {
806
  page: Page;
807
  active: Page;
808
  onClick: (page: Page) => void;
809
  label: string;
810
  glyph: string;
811
}) {
812
  return (
813
    <button className={active === page ? 'active' : ''} onClick={() => onClick(page)}>
814
      <span>{glyph}</span>
815
      {label}
816
    </button>
817
  );
818
}
819
function Metric({
820
  label,
821
  value,
822
  note,
823
  warn,
824
}: {
825
  label: string;
826
  value: number;
827
  note: string;
828
  warn?: boolean;
829
}) {
830
  return (
831
    <article className={warn ? 'metric warn' : 'metric'}>
832
      <span>{label}</span>
833
      <strong>{String(value).padStart(2, '0')}</strong>
834
      <small>{note}</small>
835
    </article>
836
  );
837
}
838
function Panel({
839
  title,
840
  subtitle,
841
  children,
842
}: React.PropsWithChildren<{ title: string; subtitle: string }>) {
843
  return (
844
    <section className="panel">
845
      <div className="panelHead">
846
        <div>
847
          <h2>{title}</h2>
848
          <p>{subtitle}</p>
849
        </div>
850
      </div>
851
      {children}
852
    </section>
853
  );
854
}
855
function Timeline({ events }: { events: Record<string, any>[] }) {
856
  return events.length ? (
857
    <div className="timeline">
858
      {events.map((event) => (
859
        <HistoryRow key={event.eventId} item={{ ...event, _kind: 'event' }} />
860
      ))}
861
    </div>
862
  ) : (
863
    <Empty
864
      title="Nothing recorded yet"
865
      text="Activity appears after your connected agents submit signed structured events."
866
    />
867
  );
868
}
869
function HistoryRow({ item }: { item: Record<string, any> }) {
870
  const kind = String(item.eventType ?? item._kind ?? 'record').replaceAll('_', ' ');
871
  const id = String(item.agentId ?? item.interactionId ?? item.receiptId ?? 'OpenClasp');
872
  return (
873
    <article className="historyRow">
874
      <span className={`eventDot ${item.eventType ?? item._kind}`} />
875
      <div>
876
        <strong>{kind}</strong>
877
        <small>
878
          {id} · {new Date(timestamp(item)).toLocaleString()}
879
        </small>
880
      </div>
881
      <b>{item.visibility ?? item.outcome ?? item.status ?? 'SIGNED'}</b>
882
    </article>
883
  );
884
}
885
function AgentCard({
886
  agent,
887
  projectName,
888
  published,
889
  working,
890
  onPublication,
891
}: {
892
  agent: Record<string, any>;
893
  projectName?: string;
894
  published: boolean;
895
  working: boolean;
896
  onPublication: (published: boolean) => void;
897
}) {
898
  const identityLabel = agent.revoked
899
    ? 'REVOKED'
900
    : agent.identityMode === 'oauth_installation'
901
      ? 'AUTHENTICATED'
902
      : 'VERIFIED';
903
  return (
904
    <article className="agentCard">
905
      <div className="agentTop">
906
        <span className="agentGlyph">◇</span>
907
        <b className={agent.revoked ? 'bad' : ''}>{identityLabel}</b>
908
      </div>
909
      <h2>{agent.name ?? agent.agentId}</h2>
910
      <p>{projectName ?? `Version ${agent.agentVersion ?? '1.0.0'}`}</p>
911
      <div className="tags">
912
        {(agent.capabilities ?? []).slice(0, 4).map((capability: string) => (
913
          <span key={capability}>{capability}</span>
914
        ))}
915
      </div>
916
      <small>
917
        {agent.framework ? `${agent.framework} · ` : ''}
918
        {agent.identityMode === 'oauth_installation' ? 'OAuth-bound · ' : 'Ed25519 · '}Created{' '}
919
        {new Date(agent.createdAt).toLocaleDateString()}
920
      </small>
921
      <button
922
        className="secondary"
923
        disabled={working || agent.status === 'revoked'}
924
        onClick={() => onPublication(!published)}
925
      >
926
        {working ? 'Updating…' : published ? 'Remove from directory' : 'Publish to directory'}
927
      </button>
928
    </article>
929
  );
930
}
931
function Profile({ profile, expanded }: { profile: Record<string, any>; expanded?: boolean }) {
932
  const dimensions = [
933
    ['Completion', profile.completion],
934
    ['Scope', profile.scope],
935
    ['Evidence', profile.evidence],
936
    ...(expanded
937
      ? [
938
          ['Communication', profile.communication],
939
          ['Deadline', profile.deadline],
940
        ]
941
      : []),
942
  ] as [string, number][];
943
  return (
944
    <article className="profile">
945
      <div>
946
        <strong>{profile.agentId}</strong>
947
        <small>
948
          {profile.taskCategory} · {profile.sampleSize} outcome(s)
949
        </small>
950
      </div>
951
      {dimensions.map(([label, value]) => (
952
        <Meter key={label} label={label} value={value ?? 0} />
953
      ))}
954
    </article>
955
  );
956
}
957
function Meter({ label, value }: { label: string; value: number }) {
958
  return (
959
    <div className="meter">
960
      <span>{label}</span>
961
      <i>
962
        <b style={{ width: `${Math.round(value * 100)}%` }} />
963
      </i>
964
      <strong>{Math.round(value * 100)}%</strong>
965
    </div>
966
  );
967
}
968
function Empty({
969
  title,
970
  text,
971
  action,
972
  onAction,
973
}: {
974
  title: string;
975
  text: string;
976
  action?: string;
977
  onAction?: () => void;
978
}) {
979
  return (
980
    <div className="empty">
981
      <span>◇</span>
982
      <strong>{title}</strong>
983
      <p>{text}</p>
984
      {action && (
985
        <button className="secondary" onClick={onAction}>
986
          {action}
987
        </button>
988
      )}
989
    </div>
990
  );
991
}
992
function TextButton({ children, onClick }: React.PropsWithChildren<{ onClick: () => void }>) {
993
  return (
994
    <button className="textButton" onClick={onClick}>
995
      {children}
996
    </button>
997
  );
998
}
999
function Setting({
1000
  label,
1001
  description,
1002
  children,
1003
}: React.PropsWithChildren<{ label: string; description: string }>) {
1004
  return (
1005
    <div className="setting">
1006
      <div>
1007
        <strong>{label}</strong>
1008
        <p>{description}</p>
1009
      </div>
1010
      <div>{children}</div>
1011
    </div>
1012
  );
1013
}
1014
function Toggle({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
1015
  return (
1016
    <button
1017
      className={checked ? 'toggle on' : 'toggle'}
1018
      role="switch"
1019
      aria-checked={checked}
1020
      onClick={() => onChange(!checked)}
1021
    >
1022
      <span />
1023
    </button>
1024
  );
1025
}
1026
function Loading({ compact }: { compact?: boolean }) {
1027
  return (
1028
    <div className={compact ? 'loading compact' : 'loading'}>
1029
      <span className="mark">OC</span>
1030
      <p>Verifying session…</p>
1031
    </div>
1032
  );
1033
}
1034
function initials(value: string) {
1035
  return value
1036
    .split(/\s+|@/)
1037
    .slice(0, 2)
1038
    .map((part) => part[0]?.toUpperCase())
1039
    .join('');
1040
}
1041
function timestamp(item: Record<string, any>) {
1042
  return String(item.timestamp ?? item.completedAt ?? item.createdAt ?? new Date(0).toISOString());
1043
}
1044
1045
if (!__AUTH0_DOMAIN__ || !__AUTH0_CLIENT_ID__ || !__AUTH0_AUDIENCE__)
1046
  throw new Error('Auth0 public configuration is incomplete');
1047
createRoot(document.getElementById('root')!).render(
1048
  <React.StrictMode>
1049
    {location.pathname === '/sso-callback' ? <AuthCallback /> : <App />}
1050
  </React.StrictMode>,
1051
);