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:
parent
a0a3f9a70c
commit
7dd147adb4
4 changed files with 469 additions and 32 deletions
|
|
@ -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.`;
|
||||
}
|
||||
112
frontend/packages/swarm-ui/src/agent/detail/AgentClaudeLogin.tsx
Normal file
112
frontend/packages/swarm-ui/src/agent/detail/AgentClaudeLogin.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
33
frontend/packages/swarm-ui/test/stored-login.test.mjs
Normal file
33
frontend/packages/swarm-ui/test/stored-login.test.mjs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// The stored claude login's routes and wording. Run by `npm test`, which Node
|
||||
// runs with its built-in test runner and type stripping, so the `.ts` module
|
||||
// is imported as it is.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
deleteMessage,
|
||||
deleteStoredLoginPath,
|
||||
storedAtMs,
|
||||
storedLoginPath,
|
||||
} from "../src/agent/claude-login/StoredLogin.ts";
|
||||
|
||||
test("the routes name the agent, escaped", () => {
|
||||
assert.equal(storedLoginPath("atlas"), "/api/agents/atlas/claude-login");
|
||||
assert.equal(
|
||||
deleteStoredLoginPath("atlas"),
|
||||
"/api/agents/atlas/claude-login/stored",
|
||||
);
|
||||
assert.equal(storedLoginPath("a/b"), "/api/agents/a%2Fb/claude-login");
|
||||
});
|
||||
|
||||
test("the time the store gives is read as an instant, or as nothing", () => {
|
||||
assert.equal(
|
||||
storedAtMs({ stored_at: "2026-11-09T20:00:00.123456789Z" }),
|
||||
Date.parse("2026-11-09T20:00:00.123Z"),
|
||||
);
|
||||
assert.equal(storedAtMs({ stored_at: null }), null);
|
||||
assert.equal(storedAtMs({ stored_at: "not a time" }), null);
|
||||
});
|
||||
|
||||
test("the delete's confirmation names the agent", () => {
|
||||
assert.match(deleteMessage("atlas"), /stored claude login for atlas/);
|
||||
});
|
||||
Loading…
Reference in a new issue