Add shared-auth-token UI and make cancelling actually cancel

UI for the shared Claude token: a Settings section showing token state
with Authenticate and Revoke, an acquisition modal built on the shared
Modal (sign-in link handed to the host browser via the opener plugin,
plus the code input that answers `setup-token`'s stdin prompt — the flow
cannot complete without it), and a per-project opt-out toggle shown only
for the Anthropic backend.

Cancellation: acquire_claude_token previously had only two exits,
completion and a 15-minute timeout, and held the single-flight guard for
the whole time. Closing the dialog therefore locked the user out of
retrying for up to 15 minutes. Adds cancel_claude_token, backed by a
oneshot claimed and released in lockstep with the input guard, selected
on in the run loop so it wins the race and tears the exec down. The
dialog's Cancel now calls it and closes either way.

Also refreshes CLAUDE.md, which had drifted: it documented the deleted
ProjectCard, and asserted that new IPC commands need permission grants
in capabilities/default.json — they do not, that file covers plugin
commands only. Adds the conventions that would otherwise bite:
container_needs_recreation() is purely label-based and never diffs env,
so container-affecting state needs its own label; and #[serde(default)]
on a bool yields false regardless of intent.

Corrects the claim that Reset preserves credentials. Reset calls
remove_project_volumes, which deletes both the home and claude-config
volumes, so it wipes ~/.claude, the OAuth token, installed skills and
session transcripts.

84 frontend tests, 34 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:49:03 -07:00
co-authored by Claude Opus 5
parent 01a2f6aec8
commit d95ba54a69
15 changed files with 1427 additions and 11 deletions
@@ -0,0 +1,205 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
import ClaudeAuthModal from "./ClaudeAuthModal";
const acquireClaudeToken = vi.fn();
const submitClaudeTokenCode = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
acquireClaudeToken: (...args: unknown[]) => acquireClaudeToken(...args),
submitClaudeTokenCode: (...args: unknown[]) => submitClaudeTokenCode(...args),
hasClaudeToken: vi.fn(),
clearClaudeToken: vi.fn(),
cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args),
}));
const cancelClaudeToken = vi.fn(() => Promise.resolve());
const openUrl = vi.fn();
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: (...args: unknown[]) => openUrl(...args),
}));
/** Captured event handlers, keyed by event name, so tests can emit. */
const handlers = new Map<string, (event: { payload: unknown }) => void>();
const unlisten = vi.fn();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, handler: (e: { payload: unknown }) => void) => {
handlers.set(name, handler);
return unlisten;
}),
}));
function emitOutput(chunk: string, projectId = "p1") {
act(() => {
handlers.get("claude-token-output")?.({
payload: { project_id: projectId, chunk },
});
});
}
function renderModal(
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
) {
return render(
<ClaudeAuthModal
projectId="p1"
projectName="api-server"
onClose={overrides.onClose ?? vi.fn()}
onAuthenticated={overrides.onAuthenticated ?? vi.fn()}
/>,
);
}
/** Both listeners register before `acquire_claude_token` is invoked. */
async function flowStarted() {
await waitFor(() => expect(acquireClaudeToken).toHaveBeenCalledWith("p1"));
}
describe("ClaudeAuthModal", () => {
beforeEach(() => {
vi.clearAllMocks();
handlers.clear();
// A flow that never resolves on its own — the CLI is sitting on its prompt.
acquireClaudeToken.mockImplementation(() => new Promise(() => {}));
submitClaudeTokenCode.mockResolvedValue(undefined);
});
it("starts the flow for the given project", async () => {
renderModal();
await flowStarted();
});
it("submits the pasted code to the backend", async () => {
renderModal();
await flowStarted();
fireEvent.change(screen.getByLabelText("Authentication code"), {
target: { value: " code-123 " },
});
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
// Trimmed on the way out — the backend rejects surrounding whitespace noise.
await waitFor(() =>
expect(submitClaudeTokenCode).toHaveBeenCalledWith("code-123"),
);
await waitFor(() =>
expect(screen.getByLabelText("Authentication code")).toHaveValue(""),
);
});
it("submits on Enter as well as on the button", async () => {
renderModal();
await flowStarted();
const input = screen.getByLabelText("Authentication code");
fireEvent.change(input, { target: { value: "code-456" } });
fireEvent.submit(input.closest("form")!);
await waitFor(() =>
expect(submitClaudeTokenCode).toHaveBeenCalledWith("code-456"),
);
});
it("refuses an empty code without calling the backend", async () => {
renderModal();
await flowStarted();
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
await screen.findByText("Enter the code shown after signing in.");
expect(submitClaudeTokenCode).not.toHaveBeenCalled();
});
it("reports a backend rejection instead of dumping the raw value", async () => {
submitClaudeTokenCode.mockRejectedValue(
"That code contains invalid characters. Copy it again and retry.",
);
renderModal();
await flowStarted();
fireEvent.change(screen.getByLabelText("Authentication code"), {
target: { value: "bad" },
});
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
await screen.findByText(
"That code contains invalid characters. Copy it again and retry.",
);
});
it("linkifies the sign-in URL from the streamed output and opens it in the host browser", async () => {
renderModal();
await flowStarted();
const url = "https://claude.ai/oauth/authorize?code=true&client_id=abc";
emitOutput(`Use this url to sign in:\n${url}\n`);
const link = await screen.findByRole("link", { name: url });
fireEvent.click(link);
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url));
});
it("ignores output belonging to a different project", async () => {
renderModal();
await flowStarted();
emitOutput("https://claude.ai/oauth/authorize?code=other", "p2");
expect(screen.getByTestId("claude-auth-output")).not.toHaveTextContent(
"code=other",
);
});
it("surfaces an actionable failure when the flow ends badly", async () => {
acquireClaudeToken.mockRejectedValue(
"`claude setup-token` finished but printed no recognisable token. Nothing was stored.",
);
renderModal();
const banner = await screen.findByTestId("claude-auth-error");
expect(banner).toHaveTextContent(/printed no recognisable token/);
});
it("announces success and notifies the caller", async () => {
acquireClaudeToken.mockResolvedValue(undefined);
const onAuthenticated = vi.fn();
renderModal({ onAuthenticated });
await screen.findByTestId("claude-auth-success");
expect(onAuthenticated).toHaveBeenCalledTimes(1);
});
it("confirms before cancelling, then aborts the container-side CLI", async () => {
const onClose = vi.fn();
renderModal({ onClose });
await flowStarted();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getByText(/no token is stored/i)).toBeInTheDocument();
// Confirming is required — the first click must not cancel anything.
expect(cancelClaudeToken).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Cancel sign-in" }));
await waitFor(() => expect(cancelClaudeToken).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("still closes when the cancel command rejects", async () => {
cancelClaudeToken.mockRejectedValueOnce(new Error("nope"));
const onClose = vi.fn();
renderModal({ onClose });
await flowStarted();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel sign-in" }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
});
it("removes its event listeners on unmount", async () => {
const { unmount } = renderModal();
await flowStarted();
unmount();
await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(2));
});
});
@@ -0,0 +1,306 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { cancelClaudeToken } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
import { inputClass } from "../ui/Field";
import {
authErrorMessage,
useClaudeTokenAcquisition,
} from "../../hooks/useClaudeAuth";
interface Props {
/** Project whose running container is borrowed to run the CLI. */
projectId: string;
projectName: string;
onClose: () => void;
/** Fired once the token has been stored, so callers can re-check status. */
onAuthenticated: () => void;
}
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
waiting: { tone: "busy", label: "Waiting for sign-in" },
finishing: { tone: "busy", label: "Finishing sign-in" },
succeeded: { tone: "ok", label: "Token stored" },
failed: { tone: "error", label: "Authentication failed" },
};
/**
* Drives one `claude setup-token` run.
*
* The CLI prints a sign-in URL, the user signs in on an Anthropic-hosted page,
* copies a code from it, and the CLI then blocks on stdin waiting for that
* code. The input below is the only way to answer that prompt, so it is the
* centre of this dialog rather than a footnote.
*
* Everything shown here is redacted backend-side; the token is never sent to
* the frontend and is never held in component state.
*/
export default function ClaudeAuthModal({
projectId,
projectName,
onClose,
onAuthenticated,
}: Props) {
const flow = useClaudeTokenAcquisition(projectId, onAuthenticated);
const [code, setCode] = useState("");
const [copied, setCopied] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
const codeRef = useRef<HTMLInputElement>(null);
const outputRef = useRef<HTMLPreElement>(null);
const running = flow.phase === "running";
// Cancelling actually aborts the container-side `setup-token` and releases
// the single-flight guard, so the user can retry immediately. Closing without
// it would leave the CLI waiting until its 15-minute timeout, blocking any
// second attempt. Best-effort: if the flow just finished on its own the
// command is a no-op, and either way the dialog closes.
const handleCancel = useCallback(() => {
cancelClaudeToken()
.catch((e) => console.error("Failed to cancel Claude authentication:", e))
.finally(onClose);
}, [onClose]);
// Follow the tail of the transcript as it streams in.
useEffect(() => {
const el = outputRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [flow.output]);
useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timer);
}, [copied]);
const status =
flow.phase === "succeeded"
? PHASE_STATUS.succeeded
: flow.phase === "failed"
? PHASE_STATUS.failed
: flow.codeSubmitted
? PHASE_STATUS.finishing
: PHASE_STATUS.waiting;
const handleOpen = async () => {
if (!flow.signInUrl) return;
setLinkError(null);
try {
await openUrl(flow.signInUrl);
} catch (e) {
setLinkError(
authErrorMessage(
e,
"Could not hand the link to your browser. Copy it and paste it in manually.",
),
);
}
};
const handleCopy = async () => {
if (!flow.signInUrl) return;
setLinkError(null);
try {
await navigator.clipboard.writeText(flow.signInUrl);
setCopied(true);
} catch (e) {
setLinkError(
authErrorMessage(
e,
"Could not copy to the clipboard. Select the link text and copy it manually.",
),
);
}
};
const handleSubmitCode = async (e: React.FormEvent) => {
e.preventDefault();
const ok = await flow.submitCode(code);
if (ok) setCode("");
};
const latestProgress = flow.progress[flow.progress.length - 1] ?? null;
return (
<Modal
title="Shared Claude authentication"
description={
<>
Running <code className="font-mono">claude setup-token</code> in{" "}
<strong className="text-[var(--text-primary)]">{projectName}</strong>&rsquo;s
container. The token it produces is shared by every project.
</>
}
widthClassName="w-[40rem]"
dismissible={!running}
onClose={onClose}
initialFocusRef={codeRef}
footer={
confirmCancel ? (
<>
<Button size="md" onClick={() => setConfirmCancel(false)}>
Keep waiting
</Button>
<Button size="md" variant="danger" onClick={handleCancel}>
Cancel sign-in
</Button>
</>
) : running ? (
<Button size="md" variant="ghost" onClick={() => setConfirmCancel(true)}>
Cancel
</Button>
) : (
<Button
size="md"
variant={flow.phase === "succeeded" ? "primary" : "secondary"}
onClick={onClose}
>
{flow.phase === "succeeded" ? "Done" : "Close"}
</Button>
)
}
>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<StatusIndicator tone={status.tone} label={status.label} className="text-xs" />
{latestProgress && (
<p
data-testid="claude-auth-progress"
className="min-w-0 flex-1 text-right text-xs text-[var(--text-secondary)] truncate"
title={latestProgress}
>
{latestProgress}
</p>
)}
</div>
{/* Step 1 — sign in. */}
<section>
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
1. Sign in with Anthropic
</h3>
{flow.signInUrl ? (
<div className="mt-1 space-y-1.5">
<div className="flex items-center gap-1.5">
<a
href={flow.signInUrl}
onClick={(e) => {
e.preventDefault();
void handleOpen();
}}
className="min-w-0 flex-1 truncate px-2.5 py-1.5 font-mono text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] transition-colors"
title={flow.signInUrl}
>
{flow.signInUrl}
</a>
<Button size="md" onClick={() => void handleOpen()}>
Open
</Button>
<Button size="md" onClick={() => void handleCopy()}>
{copied ? "Copied ✓" : "Copy"}
</Button>
</div>
<p className="text-xs text-[var(--text-secondary)] leading-snug">
Opens in your normal browser. After signing in, Anthropic shows you a
code &mdash; copy it and paste it below.
</p>
</div>
) : (
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-snug">
Waiting for <code className="font-mono">claude setup-token</code> to print
the sign-in link&hellip; It appears in the output below as soon as the CLI
starts.
</p>
)}
{linkError && (
<p className="mt-1 text-xs text-[var(--error)]">{linkError}</p>
)}
</section>
{/* Step 2 — the code. Without this the CLI sits on its stdin prompt forever. */}
<section>
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
2. Paste the code
</h3>
<form onSubmit={handleSubmitCode} className="mt-1 flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<input
ref={codeRef}
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
disabled={!running || flow.submitting}
aria-label="Authentication code"
placeholder="Paste the code from the Anthropic page"
autoComplete="off"
spellCheck={false}
className={`${inputClass} font-mono`}
/>
{flow.submitError && (
<p className="mt-1 text-xs text-[var(--error)]">{flow.submitError}</p>
)}
{!flow.submitError && flow.codeSubmitted && running && (
<p className="mt-1 text-xs text-[var(--text-secondary)]">
Code sent. Waiting for <code className="font-mono">setup-token</code>{" "}
to finish&hellip;
</p>
)}
</div>
<Button
size="md"
variant="primary"
type="submit"
disabled={!running || flow.submitting}
>
{flow.submitting ? "Sending…" : "Submit code"}
</Button>
</form>
</section>
{/* Step 3 — outcome. */}
{flow.phase === "succeeded" && (
<p
data-testid="claude-auth-success"
className="px-2.5 py-2 text-xs text-[var(--success)] bg-[var(--success-muted)] border border-[var(--success)]/40 rounded-[var(--radius-control)]"
>
Token stored in the OS keychain. Restart your Anthropic-backend containers
to start using it.
</p>
)}
{flow.phase === "failed" && flow.error && (
<p
data-testid="claude-auth-error"
className="px-2.5 py-2 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/40 rounded-[var(--radius-control)]"
>
{flow.error}
</p>
)}
{confirmCancel && (
<p className="px-2.5 py-2 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] leading-snug">
This stops <code className="font-mono">claude setup-token</code> inside the
container and discards the sign-in. No token is stored. You can start again
straight away.
</p>
)}
{/* Redacted backend-side before it is emitted; still never parsed here. */}
<section>
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
Command output
</h3>
<pre
ref={outputRef}
data-testid="claude-auth-output"
aria-label="Command output"
className="mt-1 h-40 overflow-auto whitespace-pre-wrap break-words px-2.5 py-2 font-mono text-[11px] leading-relaxed text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
>
{flow.output || "Starting `claude setup-token`…\n"}
</pre>
</section>
</div>
</Modal>
);
}
@@ -15,6 +15,7 @@ import AccordionSection from "../ui/AccordionSection";
import Toggle from "../ui/Toggle";
import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
@@ -149,6 +150,10 @@ export default function SettingsPanel() {
</div>
</AccordionSection>
<AccordionSection id="claude-auth" title="Claude Authentication" defaultOpen={false}>
<SharedAuthSettings />
</AccordionSection>
<AccordionSection id="backends" title="Backends" defaultOpen={false}>
<AwsSettings />
<div className="pt-3 border-t border-[var(--border-color)]" />
@@ -0,0 +1,126 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import SharedAuthSettings from "./SharedAuthSettings";
import type { Project } from "../../lib/types";
const hasClaudeToken = vi.fn();
const clearClaudeToken = vi.fn();
vi.mock("../../lib/tauri-commands", () => ({
hasClaudeToken: () => hasClaudeToken(),
clearClaudeToken: () => clearClaudeToken(),
acquireClaudeToken: vi.fn(),
submitClaudeTokenCode: vi.fn(),
}));
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => vi.fn()) }));
vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() }));
let projects: Project[] = [];
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({ projects }),
}));
const baseProject: Project = {
id: "p1",
name: "api-server",
paths: [{ host_path: "/src/api", mount_name: "api" }],
container_id: null,
status: "stopped",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: null,
ssh_key_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const running = (over: Partial<Project> = {}): Project => ({
...baseProject,
status: "running",
container_id: "container-1",
...over,
});
describe("SharedAuthSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
projects = [];
hasClaudeToken.mockResolvedValue(false);
});
it("disables Authenticate and says why when nothing is running", async () => {
projects = [baseProject];
render(<SharedAuthSettings />);
expect(screen.getByRole("button", { name: "Authenticate" })).toBeDisabled();
expect(screen.getByTestId("shared-auth-no-container")).toHaveTextContent(
/start a project first/i,
);
await waitFor(() => expect(hasClaudeToken).toHaveBeenCalled());
});
it("treats a running project with no container id as unusable", async () => {
projects = [running({ container_id: null })];
render(<SharedAuthSettings />);
expect(screen.getByRole("button", { name: "Authenticate" })).toBeDisabled();
await waitFor(() => expect(hasClaudeToken).toHaveBeenCalled());
});
it("enables Authenticate once a container is running", async () => {
projects = [running()];
render(<SharedAuthSettings />);
expect(screen.getByRole("button", { name: "Authenticate" })).toBeEnabled();
expect(screen.queryByTestId("shared-auth-no-container")).not.toBeInTheDocument();
await waitFor(() => expect(hasClaudeToken).toHaveBeenCalled());
});
it("offers a host picker only when more than one project is running", async () => {
projects = [running()];
const { rerender } = render(<SharedAuthSettings />);
expect(screen.queryByLabelText("Run the sign-in in")).not.toBeInTheDocument();
projects = [running(), running({ id: "p2", name: "web" })];
rerender(<SharedAuthSettings />);
expect(screen.getByLabelText("Run the sign-in in")).toBeInTheDocument();
await waitFor(() => expect(hasClaudeToken).toHaveBeenCalled());
});
it("shows Revoke only when a token is stored", async () => {
projects = [running()];
hasClaudeToken.mockResolvedValue(true);
render(<SharedAuthSettings />);
await screen.findByRole("button", { name: "Revoke" });
expect(screen.getByRole("button", { name: "Re-authenticate" })).toBeEnabled();
expect(screen.getByTestId("shared-auth-detail")).toHaveTextContent(
/A shared token is stored/,
);
});
it("reports a keychain read failure instead of claiming there is no token", async () => {
projects = [running()];
hasClaudeToken.mockRejectedValue("keyring backend unavailable");
render(<SharedAuthSettings />);
await screen.findByText("keyring backend unavailable");
expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument();
});
});
@@ -0,0 +1,227 @@
import { useState } from "react";
import Button from "../ui/Button";
import Modal from "../ui/Modal";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
import { selectClass } from "../ui/Field";
import ClaudeAuthModal from "./ClaudeAuthModal";
import { clearClaudeToken } from "../../lib/tauri-commands";
import { useProjects } from "../../hooks/useProjects";
import { useAppState } from "../../store/appState";
import { authErrorMessage, useClaudeTokenStatus } from "../../hooks/useClaudeAuth";
const STATUS_DISPLAY: Record<
string,
{ tone: StatusTone; label: string; detail: string }
> = {
checking: {
tone: "unknown",
label: "Checking",
detail: "Looking for a stored token in the OS keychain.",
},
stored: {
tone: "ok",
label: "Authenticated",
detail:
"A shared token is stored. Anthropic-backend projects use it from their next container start.",
},
absent: {
tone: "off",
label: "Not authenticated",
detail:
"No shared token yet, so each Anthropic-backend project still needs its own `claude login`.",
},
unavailable: {
tone: "error",
label: "Unknown",
detail: "The OS keychain could not be read.",
},
};
/**
* Host-level control for the one long-lived Claude Code token shared by every
* project. Acquisition needs a running container to run the CLI in, so the
* user picks which project lends one.
*/
export default function SharedAuthSettings() {
const { projects } = useProjects();
const pushToast = useAppState((s) => s.pushToast);
const { status, error, refresh } = useClaudeTokenStatus();
const [pickedId, setPickedId] = useState<string | null>(null);
const [authOpen, setAuthOpen] = useState(false);
const [confirmRevoke, setConfirmRevoke] = useState(false);
const [revoking, setRevoking] = useState(false);
// `claude setup-token` runs inside a container, so only running projects can
// host the flow.
const runnable = projects.filter(
(p) => p.status === "running" && p.container_id !== null,
);
const host = runnable.find((p) => p.id === pickedId) ?? runnable[0] ?? null;
const display = STATUS_DISPLAY[status];
const handleRevoke = async () => {
setRevoking(true);
try {
await clearClaudeToken();
setConfirmRevoke(false);
await refresh();
pushToast({
kind: "success",
message: "Shared Claude token removed from the keychain.",
});
} catch (e) {
pushToast({
kind: "error",
message: "Could not remove the shared Claude token.",
detail: authErrorMessage(
e,
"The OS keychain rejected the delete. The token may still be stored.",
),
});
} finally {
setRevoking(false);
}
};
return (
<div className="space-y-3">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--text-primary)]">
Shared Claude authentication
</span>
<StatusIndicator
tone={display.tone}
label={display.label}
className="text-xs"
/>
</div>
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-snug">
Authenticate once and every project on the Anthropic backend signs in with
that token, instead of each container running its own{" "}
<code className="font-mono">claude login</code>. The token is held in your OS
keychain and injected into containers as an environment variable.
</p>
<p
data-testid="shared-auth-detail"
className="mt-1 text-xs text-[var(--text-secondary)] leading-snug"
>
{display.detail}
</p>
{error && <p className="mt-1 text-xs text-[var(--error)]">{error}</p>}
</div>
{runnable.length > 1 && (
<div>
<label
htmlFor="shared-auth-host"
className="block text-xs text-[var(--text-secondary)] mb-1"
>
Run the sign-in in
</label>
<select
id="shared-auth-host"
value={host?.id ?? ""}
onChange={(e) => setPickedId(e.target.value)}
className={selectClass}
>
{runnable.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
)}
<div className="flex items-center gap-2">
<Button
size="md"
variant="primary"
disabled={!host}
onClick={() => setAuthOpen(true)}
>
{status === "stored" ? "Re-authenticate" : "Authenticate"}
</Button>
{status === "stored" && (
<Button
size="md"
variant="danger"
disabled={revoking}
onClick={() => setConfirmRevoke(true)}
>
Revoke
</Button>
)}
</div>
{!host && (
<p
data-testid="shared-auth-no-container"
className="text-xs text-[var(--warning)] leading-snug"
>
No project is running. Signing in runs{" "}
<code className="font-mono">claude setup-token</code> inside a container, so
start a project first &mdash; any one will do, it only lends its container.
</p>
)}
{host && (
<p className="text-xs text-[var(--text-secondary)] leading-snug">
The sign-in runs in{" "}
<strong className="text-[var(--text-primary)]">{host.name}</strong>&rsquo;s
container, but the resulting token is shared by all projects.
</p>
)}
{authOpen && host && (
<ClaudeAuthModal
projectId={host.id}
projectName={host.name}
onClose={() => setAuthOpen(false)}
onAuthenticated={() => {
void refresh();
}}
/>
)}
{confirmRevoke && (
<Modal
title="Revoke shared Claude token"
widthClassName="w-[26rem]"
onClose={() => setConfirmRevoke(false)}
footer={
<>
<Button
size="md"
variant="ghost"
onClick={() => setConfirmRevoke(false)}
disabled={revoking}
>
Cancel
</Button>
<Button
size="md"
variant="danger"
disabled={revoking}
onClick={() => void handleRevoke()}
>
{revoking ? "Revoking…" : "Revoke token"}
</Button>
</>
}
>
<p className="text-[13px] text-[var(--text-secondary)] leading-snug">
This deletes the shared token from your OS keychain. Anthropic-backend
projects fall back to their own{" "}
<code className="font-mono">claude login</code> the next time their
container starts. Existing running containers keep working until they are
restarted.
</p>
</Modal>
)}
</div>
);
}