Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: show when an agent's claude login was stored, and delete it

The agent's settings get a claude login row: when the swarm stored the
login, read by the minter from the leaf's metadata, or that none is, with a
delete that removes every version after a confirmation. New minter steps
(status, delete), controller routes (GET claude-login, DELETE
claude-login/stored) and a read and delete grant on the leaf's metadata,
pinned in the module-eval check.
This commit is contained in:
iris 2026-11-09 21:39:17 +01:00 • committed by mara
commit 7dd147adb4
3 changed files with 469 additions and 32 deletions

View file

@ -0,0 +1,31 @@
// The swarm's stored claude login for an agent, as the agent's detail shows it:
// when it was written, and the delete. Kept out of the component so `npm test`
// runs it. The routes are swarm-controller's `claude_login` module.
/** What `GET …/claude-login` answers. */
export interface StoredLogin {
/** RFC 3339, as the secret store spells it; null when nothing is stored. */
stored_at: string | null;
}
export function storedLoginPath(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/claude-login`;
}
export function deleteStoredLoginPath(agent: string): string {
return `${storedLoginPath(agent)}/stored`;
}
// The instant a stored login was written, in ms since the epoch, or null when
// nothing is stored or the store's time does not parse.
export function storedAtMs(login: StoredLogin): number | null {
if (login.stored_at === null) return null;
const ms = Date.parse(login.stored_at);
return Number.isNaN(ms) ? null : ms;
}
// What the delete's confirmation says: it removes the swarm's copy, and an
// agent that already holds a session keeps it.
export function deleteMessage(agent: string): string {
return `Delete the stored claude login for ${agent}? ${agent} has to be logged in again to get a new one.`;
}

View file

@ -0,0 +1,112 @@
// <AgentClaudeLogin> — the swarm's stored claude login for one agent: when it
// was written (`GET …/claude-login`, read from the secret store's metadata),
// or that none is, and a delete that removes every version of it
// (`DELETE …/claude-login/stored`) after a confirmation.
//
// Its state belongs to one agent: callers key it by agent name.
import { useCallback, useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Hint } from "../../ui/hint/Hint.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { showToast } from "../../ui/toast/toasts.js";
import {
deleteMessage,
deleteStoredLoginPath,
storedAtMs,
storedLoginPath,
type StoredLogin,
} from "../claude-login/StoredLogin.js";
export function AgentClaudeLogin({ agent }: { agent: string }) {
const [login, setLogin] = useState<StoredLogin | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [confirming, setConfirming] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<ProblemDetails | null>(null);
const load = useCallback(async () => {
try {
const r = await fetch(storedLoginPath(agent));
if (!r.ok) {
setError(await readApiError(r));
return;
}
setLogin((await r.json()) as StoredLogin);
setError(null);
} catch (err) {
setError({ detail: String(err) });
}
}, [agent]);
useEffect(() => {
void load();
}, [load]);
async function confirmDelete() {
setDeleting(true);
setDeleteError(null);
try {
const r = await fetch(deleteStoredLoginPath(agent), { method: "DELETE" });
if (!r.ok) {
setDeleteError(await readApiError(r));
return;
}
setConfirming(false);
showToast(`Stored login for ${agent} deleted.`, "ok");
await load();
} catch (err) {
setDeleteError({ detail: String(err) });
} finally {
setDeleting(false);
}
}
// Shown as text, not a hover title. A login already read stays under a
// failed refetch.
const errorPanel = error ? (
<ApiErrorPanel context="reading the stored login failed" problem={error} />
) : null;
if (login === null) {
return errorPanel ?? <Hint as="span">…</Hint>;
}
const at = storedAtMs(login);
return (
<>
{errorPanel}
{login.stored_at === null ? (
<Hint as="span">none stored</Hint>
) : (
<>
<span title={login.stored_at}>
stored {at === null ? login.stored_at : <RelativeTime epochMs={at} />}
</span>{" "}
<Badge
variant="quiet"
value="delete login"
title={`delete the stored claude login for ${agent}`}
onClick={() => {
setDeleteError(null);
setConfirming(true);
}}
/>
</>
)}
<ConfirmDialog
open={confirming}
label={`delete the stored claude login for ${agent}`}
confirmLabel={deleting ? "deleting…" : "delete login"}
confirmDisabled={deleting}
onCancel={() => setConfirming(false)}
onConfirm={() => void confirmDelete()}
>
<p>{deleteMessage(agent)}</p>
{deleteError ? (
<ApiErrorPanel context="delete failed" problem={deleteError} />
) : null}
</ConfirmDialog>
</>
);
}

View file

@ -17,6 +17,7 @@ import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
import { Hint } from "../../ui/hint/Hint.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import type { AgentActions } from "../roster/AgentActions.js";
import { AgentClaudeLogin } from "./AgentClaudeLogin.js";
import { AgentInference } from "./AgentInference.js";
import { AgentInstructions } from "./AgentInstructions.js";
import { AgentLimits } from "./AgentLimits.js";
@ -186,6 +187,10 @@ function SettingsRows({ agent }: { agent: string }) {
<AgentLimits key={agent} agent={agent} />
</dd>
<GrantRows key={agent} agent={agent} />
<dt>claude login</dt>
<dd>
<AgentClaudeLogin key={agent} agent={agent} />
</dd>
</>
);
}