import type { AgentToken, MadeAgentToken } from '../../core/store/AgentStore'; import { Button, HStack, Label, Popup, Spacer, TextField, Theme, ui } from '../../ui'; const WIDTH = 520; const LIFETIMES = [ { days: 30, label: '30 days' }, { days: 90, label: '90 days' }, { days: 365, label: '1 year' }, ] as const; /** `2026-10-17`: the day is enough for a token that lasts months. */ function day(seconds: number): string { return new Date(seconds * 1000).toISOString().slice(0, 10); } /** What one token is: when it was made, when it ends and when an agent last used it. */ export function tokenLine(token: AgentToken): string { const used = token.usedAt === null ? 'never used' : `last used ${day(token.usedAt)}`; return `made ${day(token.createdAt)} · expires ${day(token.expiresAt)} · ${used}`; } /** The line that sets the editor up in Claude Code, with the token in it. */ export function claudeCommand(endpoint: string, token: string): string { return `claude mcp add --transport http feuerwerk ${endpoint} --header "Authorization: Bearer ${token}"`; } /** * "Agent tokens…" in the account menu (#429): the account's tokens with Revoke, and a new one made * from a name and a lifetime. A token made is shown here once, with the line that sets it up in * Claude Code; the server keeps only its hash. Every action closes the dialog and hands the choice * on, and the caller opens it again on the new state, like the Release dialog. */ export class AgentTokensDialog extends Popup { private readonly nameField: TextField; private days: number = LIFETIMES[1].days; private readonly made: MadeAgentToken | null; private readonly command: string | null; constructor( private readonly tokens: readonly AgentToken[], made: MadeAgentToken | null, endpoint: string, actions: { onCreate(name: string, days: number): void; onRevoke(token: AgentToken): void; onCopy(text: string, what: string): void; }, ) { super({ modal: true, padding: Theme.padding * 2, gap: Theme.gap }); this.minWidth = WIDTH; this.made = made; this.command = made && claudeCommand(endpoint, made.token); this.content.add(new Label('Agent tokens', { style: 'bodyBold' })); this.content.add( new Label( 'A token lets an agent such as Claude Code work in this editor while "Agent" is switched on in ' + 'the account menu. It opens nothing else.', { style: 'muted', wrap: true }, ), ); if (made && this.command) { this.content.add( new Label(`"${made.name}" – copy it now, it is not shown again:`, { style: 'warning' }), ); this.content.add(this.copyRow(made.token, 'Copy token', () => actions.onCopy(made.token, 'Token'))); this.content.add(new Label('Set it up in Claude Code:', { style: 'muted' })); const command = this.command; this.content.add(this.copyRow(command, 'Copy command', () => actions.onCopy(command, 'Command'))); } if (tokens.length === 0) this.content.add(new Label('No tokens yet.', { style: 'muted' })); for (const token of tokens) { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.minHeight = Theme.rowHeight; row.add(new Label(token.name)); row.add(new Label(tokenLine(token), { style: 'small' }), 1); row.add( new Button('Revoke', { variant: 'danger', onClick: this.closeThen(() => actions.onRevoke(token)) }), ); this.content.add(row); } const create = new HStack({ gap: Theme.gap, align: 'center' }); this.nameField = new TextField('', { placeholder: 'Name, e.g. Claude Code', width: 200 }); create.add(this.nameField, 1); const lifetimes = LIFETIMES.map(({ days, label }) => { const button = new Button(label, { toggle: true }); button.setToggled(days === this.days); return { days, button }; }); for (const { days, button } of lifetimes) { button.onClick = () => { this.days = days; for (const other of lifetimes) other.button.setToggled(other.days === days); }; create.add(button); } const make = () => { const name = this.nameField.value.trim(); if (name !== '') this.closeThen(() => actions.onCreate(name, this.days))(); }; this.nameField.onSubmit = make; create.add(new Button('New token', { variant: 'primary', onClick: make })); this.content.add(create); const end = new HStack({ gap: Theme.gap, align: 'center' }); end.add(new Spacer(), 1); end.add(new Button('Close', { onClick: this.closeThen() })); this.content.add(end); } focusName(): void { ui().focus.focus(this.nameField); } /** What the dialog shows, for the check scripts. */ info(): { tokens: string[]; made: string | null; command: string | null } { return { tokens: this.tokens.map((token) => token.name), made: this.made?.token ?? null, command: this.command, }; } private copyRow(text: string, caption: string, onCopy: () => void): HStack { const row = new HStack({ gap: Theme.gap, align: 'center' }); row.add(new TextField(text, { readOnly: true, width: WIDTH - 130 }), 1); row.add(new Button(caption, { onClick: onCopy })); return row; } }