@startsimpli/integrations 0.2.28 → 0.2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.2.28",
3
+ "version": "0.2.30",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * EVERY PAGE COMPOSER IS REACHABLE FROM THE PACKAGE ROOT.
3
+ *
4
+ * WHY THIS EXISTS. `PlanningPage` shipped in the tarball — the FILE was there,
5
+ * at `src/components/pages/PlanningPage.tsx` — while the one line exporting it
6
+ * from `src/components/index.ts` had been dropped by an unrelated PR's merge
7
+ * resolution. Nothing caught it: type-check passes (the file compiles), the unit
8
+ * tests pass (they import the module directly), and `npm pack` includes it. The
9
+ * failure surfaced two publishes later, in a CUSTOMER'S docker build:
10
+ *
11
+ * The export PlanningPage was not found in module
12
+ * .../@startsimpli/integrations/src/index.ts
13
+ * Did you mean to import ConnectionsPage?
14
+ *
15
+ * That is the worst possible place to find it — a tenant's deploy, against a
16
+ * published artifact, with the composer's own tests green the whole time.
17
+ *
18
+ * So the barrel is asserted against the DIRECTORY rather than against a list
19
+ * somebody has to remember to update. A new composer is exported or this fails;
20
+ * a merge that drops an export line fails here instead of in a deploy.
21
+ */
22
+ import { readdirSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { describe, expect, it } from 'vitest';
25
+
26
+ import * as pkg from '../index';
27
+
28
+ const PAGES_DIR = join(__dirname, '..', 'components', 'pages');
29
+
30
+ /** Files that are not composers and are deliberately not re-exported by name. */
31
+ const NOT_A_COMPOSER = new Set([
32
+ // Shared prop/api types — re-exported explicitly and selectively by the barrel
33
+ // so a fork gets the ones it needs without the internal slot unions.
34
+ 'types.ts',
35
+ // The shared pieces two composers mount; exported by NAME, asserted below.
36
+ 'work-items.tsx',
37
+ // A SUB-COMPONENT, not a composer: ConnectionsPage is its only mount, and the
38
+ // roster is the one list on that screen that shows OTHER people's accounts —
39
+ // it is meaningless outside the page that gates it. Listed here rather than
40
+ // exported, so this stays a real assertion about what a fork can reach and not
41
+ // a rubber stamp.
42
+ 'MemberConnectionRoster.tsx',
43
+ ]);
44
+
45
+ function composerNames(): string[] {
46
+ return readdirSync(PAGES_DIR)
47
+ .filter((f) => f.endsWith('.tsx') && !NOT_A_COMPOSER.has(f))
48
+ .map((f) => f.replace(/\.tsx$/, ''));
49
+ }
50
+
51
+ describe('package barrel', () => {
52
+ it('re-exports every page composer in components/pages', () => {
53
+ const missing = composerNames().filter((name) => !(name in pkg));
54
+ expect(missing, `not exported from @startsimpli/integrations: ${missing.join(', ')}`)
55
+ .toEqual([]);
56
+ });
57
+
58
+ it('exports the pieces the tracker and the planner share', () => {
59
+ // These are what stop the two screens drifting. If the barrel loses them, a
60
+ // fork silently reimplements the row and the divergence starts.
61
+ for (const name of ['WorkItemRow', 'UnfilteredNotice', 'isOverdue', 'compareForScreen']) {
62
+ expect(pkg, `missing shared export ${name}`).toHaveProperty(name);
63
+ }
64
+ });
65
+
66
+ it('exports the planning client alongside its screen', () => {
67
+ // A screen with no client is a mount nobody can wire; they ship together or
68
+ // the fork gets a confusing half-error at build time.
69
+ expect(pkg).toHaveProperty('PlanningPage');
70
+ expect(pkg).toHaveProperty('createPlanningApi');
71
+ });
72
+
73
+ it('still exports the composers that were already shipping', () => {
74
+ for (const name of ['ConnectionsPage', 'LibraryPage', 'AskPage', 'TrackerPage']) {
75
+ expect(pkg, `regressed export ${name}`).toHaveProperty(name);
76
+ }
77
+ });
78
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The "connect an MCP client" composer (bd startsim-62dca).
3
+ *
4
+ * THE ASSERTION THIS FILE EXISTS FOR is `never mints without a click`. Every
5
+ * other surface in this package reads; this one ISSUES A CREDENTIAL that reads a
6
+ * workspace as a human, lasts weeks, and cannot be withdrawn before it expires
7
+ * (startsim-ja8mt). A mint fired from an effect, a retry, or a re-render would
8
+ * scatter live bearers nobody asked for and nobody can cancel — and it would do
9
+ * it silently, because each one succeeds. So the number of POSTs is asserted,
10
+ * not assumed.
11
+ *
12
+ * The rest pins the four disclosures the bead requires. They are not decoration:
13
+ * a screen that renders a copy button without them is lying by omission about
14
+ * what the user is about to hand over.
15
+ */
16
+ import { describe, expect, it, vi } from 'vitest';
17
+ import { render, screen, waitFor } from '@testing-library/react';
18
+ import userEvent from '@testing-library/user-event';
19
+
20
+ import { ConnectClaudePage } from '../components/pages/ConnectClaudePage';
21
+ import type { McpTokensApi } from '../api/mcp-tokens';
22
+
23
+ const MINTED = {
24
+ token: 'eyJhbGciOiJSUzI1NiJ9.PAYLOAD.SIG',
25
+ token_type: 'Bearer',
26
+ foundry: 'tandem',
27
+ audience: 'tenant:tandem',
28
+ acting_as: { org_id: 'tandem', role: 'owner', email: 'qa-tandem@startsimpli.com' },
29
+ jti: 'abc123',
30
+ issued_at: '2026-08-27T00:00:00Z',
31
+ expires_at: '2026-09-26T00:00:00Z',
32
+ mcp_url: 'https://tandem.ai.startsimpli.com/api/v1/mcp/',
33
+ claude_code_command:
34
+ 'claude mcp add --transport http tandem https://tandem.ai.startsimpli.com/api/v1/mcp/ --header "Authorization: Bearer eyJ…"',
35
+ notice: 'Shown once — store it like a password. It cannot be revoked before it expires.',
36
+ };
37
+
38
+ function stubApi(overrides: Partial<McpTokensApi> = {}): McpTokensApi {
39
+ return {
40
+ list: vi.fn().mockResolvedValue({
41
+ foundries: [
42
+ { slug: 'tandem', name: 'Tandem', audience: 'tenant:tandem', mcp_url: MINTED.mcp_url, state: 'live' },
43
+ ],
44
+ default_ttl_days: 30,
45
+ max_ttl_days: 90,
46
+ }),
47
+ mint: vi.fn().mockResolvedValue(MINTED),
48
+ ...overrides,
49
+ };
50
+ }
51
+
52
+ describe('ConnectClaudePage', () => {
53
+ it('NEVER mints without a click', async () => {
54
+ const api = stubApi();
55
+ render(<ConnectClaudePage api={api} />);
56
+ await waitFor(() => expect(api.list).toHaveBeenCalled());
57
+ // Give any stray effect a chance to fire before asserting the absence.
58
+ await new Promise((r) => setTimeout(r, 25));
59
+ expect(api.mint).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it('mints exactly once per click, never once per render', async () => {
63
+ const api = stubApi();
64
+ const { rerender } = render(<ConnectClaudePage api={api} />);
65
+ await screen.findByRole('button', { name: /create connection token/i });
66
+ await userEvent.click(screen.getByRole('button', { name: /create connection token/i }));
67
+ await screen.findByText(/copy it now/i);
68
+ rerender(<ConnectClaudePage api={api} />);
69
+ expect(api.mint).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it('warns about the consequences BEFORE the token exists', async () => {
73
+ // A warning that only appears next to the credential is one nobody can act
74
+ // on — by then it has been issued and cannot be taken back.
75
+ render(<ConnectClaudePage api={stubApi()} />);
76
+ const note = await screen.findByRole('note');
77
+ expect(note.textContent).toMatch(/as\s+you/i);
78
+ expect(note.textContent).toMatch(/cannot be cancelled before it expires/i);
79
+ expect(note.textContent).toMatch(/shown once/i);
80
+ // ...and specifically that losing the seat does not switch it off.
81
+ expect(note.textContent).toMatch(/does not switch this one off/i);
82
+ });
83
+
84
+ it('shows WHOSE data the token reads, from the server, not the browser', async () => {
85
+ render(<ConnectClaudePage api={stubApi()} />);
86
+ await userEvent.click(await screen.findByRole('button', { name: /create connection token/i }));
87
+ // Scoped to the identity card, because the slug legitimately appears in
88
+ // several places on this page (the command, the URL) and a bare text query
89
+ // would pass on any of them rather than on the one that states WHOSE.
90
+ const identity = (await screen.findByText(/This connection reads/i))
91
+ .closest('div') as HTMLElement;
92
+ expect(identity.textContent).toMatch(/qa-tandem@startsimpli\.com/);
93
+ expect(identity.textContent).toMatch(/owner/);
94
+ expect(identity.textContent).toMatch(/tandem/);
95
+ });
96
+
97
+ it('shows when it expires as a date, not an ISO string', async () => {
98
+ render(<ConnectClaudePage api={stubApi()} />);
99
+ await userEvent.click(await screen.findByRole('button', { name: /create connection token/i }));
100
+ const expiry = await screen.findByText(/Expires/);
101
+ expect(expiry.textContent).not.toMatch(/2026-09-26T00:00:00Z/);
102
+ expect(expiry.textContent).toMatch(/2026/);
103
+ });
104
+
105
+ it('renders the command the SERVER composed, not one it builds itself', async () => {
106
+ // The tenant's own record knows its host; a vanity domain would break a
107
+ // pattern assembled in the browser.
108
+ render(<ConnectClaudePage api={stubApi()} />);
109
+ await userEvent.click(await screen.findByRole('button', { name: /create connection token/i }));
110
+ expect(await screen.findByText(/claude mcp add --transport http tandem/)).toBeTruthy();
111
+ });
112
+
113
+ it('surfaces a refused mint as the reason, not as an outage', async () => {
114
+ // The listing is advisory; central decides membership independently, so a
115
+ // 403 here is an ordinary answer the user has to be able to read.
116
+ const api = stubApi({ mint: vi.fn().mockRejectedValue(new Error('You do not have an active seat in `tandem`')) });
117
+ render(<ConnectClaudePage api={api} />);
118
+ await userEvent.click(await screen.findByRole('button', { name: /create connection token/i }));
119
+ const alert = await screen.findByRole('alert');
120
+ expect(alert.textContent).toMatch(/active seat/i);
121
+ expect(screen.queryByText(/copy it now/i)).toBeNull();
122
+ });
123
+
124
+ it('says so plainly when the account is in no workspace', async () => {
125
+ const api = stubApi({
126
+ list: vi.fn().mockResolvedValue({ foundries: [], default_ttl_days: 30, max_ttl_days: 90 }),
127
+ });
128
+ render(<ConnectClaudePage api={api} />);
129
+ expect(await screen.findByText(/not a member of any workspace/i)).toBeTruthy();
130
+ expect(screen.queryByRole('button', { name: /create connection token/i })).toBeNull();
131
+ });
132
+
133
+ it('names no foundry, vendor or product of its own', async () => {
134
+ // Everything specific must arrive as DATA. Rendering an empty list must not
135
+ // leak a hardcoded example workspace into the page.
136
+ const api = stubApi({
137
+ list: vi.fn().mockResolvedValue({ foundries: [], default_ttl_days: 30, max_ttl_days: 90 }),
138
+ });
139
+ const { container } = render(<ConnectClaudePage api={api} />);
140
+ await screen.findByText(/not a member of any workspace/i);
141
+ expect(container.textContent).not.toMatch(/tandem|mcr|marketing-agents/i);
142
+ });
143
+ });
package/src/api/index.ts CHANGED
@@ -3,4 +3,7 @@ export * from './calendar';
3
3
  export * from './email';
4
4
  export * from './accounts';
5
5
  export * from './connections';
6
+ // The MCP bearer mint (bd startsim-hj5zh / startsim-62dca). Central, not the
7
+ // tenant — it rides the tenant's own /api/v1/auth/ proxy, see the module.
8
+ export * from './mcp-tokens';
6
9
  export { setApiBase, getApiBase, setAuthTokenProvider } from './_fetch';
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `/api/v1/auth/mcp-token/` — the long-lived bearer an MCP client holds for ONE
3
+ * foundry (bd startsim-hj5zh, surfaced by startsim-62dca).
4
+ *
5
+ * WHY THIS IS NOT A CONNECTION CLIENT even though it lives beside them. Every
6
+ * other client in this directory talks to the tenant or to the connectors API
7
+ * about a vendor account WE hold. This one talks to CENTRAL about a credential
8
+ * the user is about to hand to software we do not run. That inversion is the
9
+ * whole reason the composer over it is mostly warnings.
10
+ *
11
+ * IT MUST REACH CENTRAL, NOT THE TENANT. `request()` prefixes the configured
12
+ * base, and inside a tenant app that base points at the tenant's own same-origin
13
+ * proxy — which forwards `/api/v1/auth/*` to central (see the tenant nginx: the
14
+ * `/api/v1/auth/` location proxies to api.startsimpli.com, above the `/api/`
15
+ * location that would otherwise send it to the tenant's Django). So the ordinary
16
+ * base is correct here and no cross-origin call is made with a bearer on it.
17
+ * A client that hardcoded central's host would break that property.
18
+ *
19
+ * TWO VERBS, and the asymmetry is deliberate: `list` is safe to call on render,
20
+ * `mint` is not. Minting ISSUES A CREDENTIAL — it is not idempotent in any way a
21
+ * user cares about, because every call produces another bearer that stays valid
22
+ * until it expires and cannot be withdrawn (startsim-ja8mt). Nothing in this
23
+ * package may call `mint` from an effect.
24
+ */
25
+ import { request } from './_fetch';
26
+
27
+ /** A foundry the signed-in account may connect a client to. */
28
+ export interface McpFoundry {
29
+ slug: string;
30
+ name: string;
31
+ audience: string;
32
+ mcp_url: string;
33
+ state: string;
34
+ }
35
+
36
+ export interface McpFoundryList {
37
+ foundries: McpFoundry[];
38
+ default_ttl_days: number;
39
+ max_ttl_days: number;
40
+ }
41
+
42
+ /**
43
+ * The mint's answer. `token` is present exactly once, in this response, and is
44
+ * never retrievable again — the server stores no copy it could hand back.
45
+ */
46
+ export interface McpMintedToken {
47
+ token: string;
48
+ token_type: string;
49
+ foundry: string;
50
+ audience: string;
51
+ /**
52
+ * WHOSE data this reads. All three are DB-derived by central, never taken from
53
+ * the request, so this is the identity the tenant will actually act as — the
54
+ * thing a user has to see before pasting the token anywhere.
55
+ */
56
+ acting_as: { org_id: string | null; role: string | null; email: string | null };
57
+ jti: string | null;
58
+ issued_at: string;
59
+ expires_at: string;
60
+ mcp_url: string;
61
+ claude_code_command: string | null;
62
+ notice: string;
63
+ }
64
+
65
+ export interface McpTokensApi {
66
+ list(): Promise<McpFoundryList>;
67
+ mint(input: { foundry: string; ttl_days?: number }): Promise<McpMintedToken>;
68
+ }
69
+
70
+ const BASE = '/auth/mcp-token';
71
+
72
+ export const mcpTokens: McpTokensApi = {
73
+ /**
74
+ * The foundries this account holds an active seat in. Advisory — central
75
+ * refuses the mint independently — so a slug that appears here can still be
76
+ * denied, and the composer has to render that denial rather than assume the
77
+ * list was authoritative.
78
+ */
79
+ list(): Promise<McpFoundryList> {
80
+ return request<McpFoundryList>(`${BASE}/`);
81
+ },
82
+
83
+ mint(input: { foundry: string; ttl_days?: number }): Promise<McpMintedToken> {
84
+ return request<McpMintedToken>(`${BASE}/`, { method: 'POST', body: input });
85
+ },
86
+ };
@@ -12,6 +12,10 @@ export * from './ReconsentPromptCard';
12
12
  // one level into each app.
13
13
  export * from './CredentialSchemaForm';
14
14
  export * from './pages/ConnectionsPage';
15
+ // "Connect an MCP client" — a sibling composer, deliberately in the same place
16
+ // as ConnectionsPage: from a user's point of view both are "connect a thing to
17
+ // this workspace", even though this one hands a credential OUT (startsim-62dca).
18
+ export * from './pages/ConnectClaudePage';
15
19
  export type {
16
20
  ConnectionsApi,
17
21
  ConnectorCatalogEntry,
@@ -0,0 +1,270 @@
1
+ /**
2
+ * CONNECT AN MCP CLIENT to this workspace, as one composer (bd startsim-62dca).
3
+ *
4
+ * `<ConnectClaudePage api={mcpTokens} />` is the whole integration. An app owns
5
+ * theme classNames and a ten-line wrapper — same shape as `ConnectionsPage`, and
6
+ * for the same reason: shipping primitives without the page-level composer just
7
+ * moves the duplication up one level.
8
+ *
9
+ * THIS SCREEN IS MOSTLY WARNINGS, AND THAT IS THE DESIGN. Every other surface in
10
+ * this package helps somebody connect a vendor account WE hold. This one issues
11
+ * a credential the user then pastes into software we do not run, which reads
12
+ * their workspace as them, lasts weeks, and — until bd startsim-ja8mt lands —
13
+ * CANNOT BE WITHDRAWN before it expires. Deactivating the member's seat stops
14
+ * the next mint, not this token. A screen that rendered a copy button and no
15
+ * consequences would be lying by omission, so the consequences are not tucked
16
+ * into a tooltip: they are on the page, next to the token, in the four places
17
+ * the bead names.
18
+ *
19
+ * NOTHING HERE NAMES A FOUNDRY, a vendor, or a client product beyond the command
20
+ * the server itself composed. The foundry list arrives as data from central, and
21
+ * `claude_code_command` is built server-side from the tenant's own record — so a
22
+ * foundry on a vanity domain gets its own host rather than a pattern this file
23
+ * guessed. An `if (slug === ...)` here would undo that.
24
+ *
25
+ * MINTING IS NEVER AUTOMATIC. `list()` runs on mount; `mint()` runs only from a
26
+ * click. Every call to it issues another unrevokable bearer, so a re-render, a
27
+ * retry, or a `useEffect` that "just refreshes" would quietly scatter live
28
+ * credentials. That is why the mint state machine below is explicit rather than
29
+ * a query hook.
30
+ */
31
+ import * as React from 'react';
32
+
33
+ import type { McpFoundry, McpMintedToken, McpTokensApi } from '../../api/mcp-tokens';
34
+
35
+ type ClassNames = Partial<Record<
36
+ 'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error' | 'warning' | 'code', string
37
+ >>;
38
+
39
+ export interface ConnectClaudePageProps {
40
+ api: McpTokensApi;
41
+ classNames?: ClassNames;
42
+ /** Pre-select a foundry. Optional — with one seat the list is the answer. */
43
+ foundrySlug?: string;
44
+ }
45
+
46
+ const FALLBACK: Required<Pick<ClassNames,
47
+ 'root' | 'section' | 'heading' | 'card' | 'button' | 'muted' | 'error' | 'warning' | 'code'>> = {
48
+ root: 'space-y-6',
49
+ section: 'space-y-3',
50
+ heading: 'text-lg font-semibold',
51
+ card: 'rounded-lg border p-4',
52
+ button: 'rounded-md border px-3 py-2 text-sm font-medium',
53
+ muted: 'text-sm opacity-70',
54
+ error: 'rounded-md border p-3 text-sm',
55
+ warning: 'rounded-md border p-3 text-sm',
56
+ code: 'block w-full overflow-x-auto rounded p-3 font-mono text-xs',
57
+ };
58
+
59
+ function cx(theme: ClassNames | undefined, key: keyof typeof FALLBACK): string {
60
+ return theme?.[key] ?? FALLBACK[key];
61
+ }
62
+
63
+ /** Local-date rendering, so "expires" is not an ISO string a person has to parse. */
64
+ function readableDate(iso: string): string {
65
+ const d = new Date(iso);
66
+ return Number.isNaN(d.getTime()) ? iso : d.toLocaleDateString(undefined, {
67
+ year: 'numeric', month: 'long', day: 'numeric',
68
+ });
69
+ }
70
+
71
+ function daysUntil(iso: string): number | null {
72
+ const d = new Date(iso);
73
+ if (Number.isNaN(d.getTime())) return null;
74
+ return Math.max(0, Math.round((d.getTime() - Date.now()) / 86_400_000));
75
+ }
76
+
77
+ export function ConnectClaudePage({ api, classNames, foundrySlug }: ConnectClaudePageProps) {
78
+ const [list, setList] = React.useState<{ foundries: McpFoundry[]; maxTtl: number; defaultTtl: number } | null>(null);
79
+ const [selected, setSelected] = React.useState<string>(foundrySlug ?? '');
80
+ const [ttlDays, setTtlDays] = React.useState<number | ''>('');
81
+ const [minted, setMinted] = React.useState<McpMintedToken | null>(null);
82
+ const [minting, setMinting] = React.useState(false);
83
+ const [error, setError] = React.useState<string>('');
84
+ const [copied, setCopied] = React.useState<'' | 'token' | 'command'>('');
85
+
86
+ React.useEffect(() => {
87
+ let live = true;
88
+ api.list().then(
89
+ (r) => {
90
+ if (!live) return;
91
+ setList({ foundries: r.foundries, maxTtl: r.max_ttl_days, defaultTtl: r.default_ttl_days });
92
+ setSelected((cur) => cur || (r.foundries.length === 1 ? r.foundries[0].slug : cur));
93
+ },
94
+ (e: unknown) => { if (live) setError(e instanceof Error ? e.message : String(e)); },
95
+ );
96
+ return () => { live = false; };
97
+ }, [api]);
98
+
99
+ async function doMint() {
100
+ if (!selected || minting) return;
101
+ setMinting(true);
102
+ setError('');
103
+ try {
104
+ // The ONLY call site. See the module docstring: nothing may mint from an
105
+ // effect, a retry, or a render.
106
+ const r = await api.mint({ foundry: selected, ...(ttlDays === '' ? {} : { ttl_days: ttlDays }) });
107
+ setMinted(r);
108
+ } catch (e: unknown) {
109
+ // A 403 here is the ordinary case, not an outage: the listing is advisory
110
+ // and central decides membership independently. Show what it said.
111
+ setError(e instanceof Error ? e.message : String(e));
112
+ } finally {
113
+ setMinting(false);
114
+ }
115
+ }
116
+
117
+ async function copy(text: string, which: 'token' | 'command') {
118
+ try {
119
+ await navigator.clipboard.writeText(text);
120
+ setCopied(which);
121
+ window.setTimeout(() => setCopied(''), 2000);
122
+ } catch {
123
+ // A denied clipboard permission must not read as a failed mint — the token
124
+ // is on screen and selectable either way.
125
+ setCopied('');
126
+ }
127
+ }
128
+
129
+ if (error && !list) {
130
+ return <div className={cx(classNames, 'error')} role="alert">{error}</div>;
131
+ }
132
+ if (!list) {
133
+ return <p className={cx(classNames, 'muted')}>Loading…</p>;
134
+ }
135
+ if (list.foundries.length === 0) {
136
+ return (
137
+ <div className={cx(classNames, 'card')}>
138
+ <p className={cx(classNames, 'muted')}>
139
+ You are not a member of any workspace that can be connected yet. Ask an
140
+ owner or admin to invite you, then come back.
141
+ </p>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ const chosen = list.foundries.find((f) => f.slug === selected);
147
+
148
+ return (
149
+ <div className={cx(classNames, 'root')}>
150
+ {!minted && (
151
+ <section className={cx(classNames, 'section')}>
152
+ {list.foundries.length > 1 && (
153
+ <label className="block">
154
+ <span className={cx(classNames, 'heading')}>Which workspace?</span>
155
+ <select
156
+ className="mt-1 block w-full rounded-md border px-3 py-2"
157
+ value={selected}
158
+ onChange={(e) => setSelected(e.target.value)}
159
+ >
160
+ <option value="">Choose…</option>
161
+ {list.foundries.map((f) => (
162
+ <option key={f.slug} value={f.slug}>{f.name}</option>
163
+ ))}
164
+ </select>
165
+ </label>
166
+ )}
167
+
168
+ <label className="block">
169
+ <span className={cx(classNames, 'heading')}>How long should it last?</span>
170
+ <input
171
+ type="number"
172
+ min={1}
173
+ max={list.maxTtl}
174
+ placeholder={`${list.defaultTtl} days`}
175
+ className="mt-1 block w-full rounded-md border px-3 py-2"
176
+ value={ttlDays}
177
+ onChange={(e) => setTtlDays(e.target.value === '' ? '' : Number(e.target.value))}
178
+ />
179
+ <span className={cx(classNames, 'muted')}>
180
+ Up to {list.maxTtl} days. Shorter is safer — this cannot be
181
+ cancelled early, so the expiry date is the only way it stops working.
182
+ </span>
183
+ </label>
184
+
185
+ {/* The consequences, BEFORE the button rather than after the token.
186
+ A warning that appears only once the credential exists is a warning
187
+ nobody can act on. */}
188
+ <div className={cx(classNames, 'warning')} role="note">
189
+ <strong>Before you create this:</strong> it reads this workspace <em>as
190
+ you</em> — whatever you can see, it can see. It is shown once and
191
+ cannot be retrieved again, and it <strong>cannot be cancelled before
192
+ it expires</strong>. Turning off your access to the workspace stops
193
+ new ones being made; it does not switch this one off.
194
+ </div>
195
+
196
+ <button
197
+ type="button"
198
+ className={cx(classNames, 'button')}
199
+ disabled={!selected || minting}
200
+ onClick={doMint}
201
+ >
202
+ {minting ? 'Creating…' : 'Create connection token'}
203
+ </button>
204
+
205
+ {error && <div className={cx(classNames, 'error')} role="alert">{error}</div>}
206
+ </section>
207
+ )}
208
+
209
+ {minted && (
210
+ <section className={cx(classNames, 'section')}>
211
+ <h2 className={cx(classNames, 'heading')}>Your token — copy it now</h2>
212
+
213
+ {/* WHOSE DATA, stated first. The mint derives these server-side from
214
+ the DB, so this is the identity the workspace will actually act as
215
+ — not what the browser asked for. */}
216
+ <div className={cx(classNames, 'card')}>
217
+ <p className={cx(classNames, 'muted')}>This connection reads</p>
218
+ <p>
219
+ <strong>{minted.foundry}</strong>
220
+ {minted.acting_as.email ? <> as <strong>{minted.acting_as.email}</strong></> : null}
221
+ {minted.acting_as.role ? <> ({minted.acting_as.role})</> : null}
222
+ </p>
223
+ <p className={cx(classNames, 'muted')}>
224
+ Expires {readableDate(minted.expires_at)}
225
+ {daysUntil(minted.expires_at) !== null ? ` — about ${daysUntil(minted.expires_at)} days` : ''}
226
+ </p>
227
+ </div>
228
+
229
+ <div className={cx(classNames, 'warning')} role="alert">
230
+ {minted.notice}
231
+ </div>
232
+
233
+ {minted.claude_code_command && (
234
+ <div className={cx(classNames, 'section')}>
235
+ <p className={cx(classNames, 'muted')}>Run this in your terminal:</p>
236
+ <code className={cx(classNames, 'code')}>{minted.claude_code_command}</code>
237
+ <button
238
+ type="button"
239
+ className={cx(classNames, 'button')}
240
+ onClick={() => copy(minted.claude_code_command as string, 'command')}
241
+ >
242
+ {copied === 'command' ? 'Copied' : 'Copy command'}
243
+ </button>
244
+ </div>
245
+ )}
246
+
247
+ <details>
248
+ <summary className={cx(classNames, 'muted')}>Just the token</summary>
249
+ <code className={cx(classNames, 'code')}>{minted.token}</code>
250
+ <button
251
+ type="button"
252
+ className={cx(classNames, 'button')}
253
+ onClick={() => copy(minted.token, 'token')}
254
+ >
255
+ {copied === 'token' ? 'Copied' : 'Copy token'}
256
+ </button>
257
+ </details>
258
+
259
+ <button
260
+ type="button"
261
+ className={cx(classNames, 'button')}
262
+ onClick={() => { setMinted(null); setCopied(''); }}
263
+ >
264
+ Done
265
+ </button>
266
+ </section>
267
+ )}
268
+ </div>
269
+ );
270
+ }