Drop MCP management; add permission modes, Project Home, Auth Bridge and shared auth #13

Merged
jknapp merged 14 commits from feature/drop-mcp-tab into main 2026-08-10 05:43:09 +00:00
15 changed files with 1427 additions and 11 deletions
Showing only changes of commit d95ba54a69 - Show all commits
+58 -8
View File
@@ -56,22 +56,56 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
### Frontend Structure (`app/src/`)
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI)
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
- **`components/settings/`** — Settings panels for API keys, Docker, AWS, Web Terminal
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
and the editors reused by Project Home
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
modals — see "UI conventions" below.
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
`SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip`
### UI conventions
- **Project config belongs in Project Home's Config tab, not a modal.** Modals are reserved for
short, genuinely modal tasks (add project, confirm removal, token acquisition). The app
previously had ~12 hand-rolled modals; they were consolidated deliberately.
- **Never bypass the design tokens.** All colour comes from CSS custom properties in `index.css`.
Filled buttons use `--accent-emphasis` (not `--accent`, which fails WCAG AA against white).
Use `--text-disabled` rather than `disabled:opacity-50`.
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump.
`Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal.
### Backend Structure (`app/src-tauri/src/`)
- **`commands/`** — Tauri command handlers (docker, project, settings, terminal). These are the IPC entry points called by `invoke()`.
- **`commands/`** — Tauri command handlers. These are the IPC entry points called by `invoke()`.
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
`auth_token_commands.rs`.
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
API via `socat`. Opt-in per project.
- **`docker/`** — Docker API layer using bollard:
- `client.rs` — Singleton Docker connection via `OnceLock`
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
- `exec.rs`PTY exec sessions with bidirectional stdin/stdout streaming
- `exec.rs`Attached exec streaming. `create_attached_exec()` is the **single** place an
attached exec is opened; terminal sessions and the auth bridge both go through it.
- `image.rs` — Image build/pull with progress streaming
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
users have migrated.
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
@@ -87,7 +121,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`) so OAuth tokens survive even container resets.
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
**Reset is the exception and it is destructive.** `rebuild_project_container` calls
`remove_project_volumes`, which deletes *both* volumes — so a Reset wipes `~/.claude`,
`~/.claude.json`, the OAuth credential, installed skills, and session transcripts. That is
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
preserving credentials.
### Authentication
@@ -108,8 +148,18 @@ Per-project, independently configured:
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
- Tauri v2 permissions are declared in `capabilities/default.json` — new IPC commands need permission grants there
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
need an entry there — adding one is not required and none exists for any app command.
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
- **Adding project state that changes the container?** `container_needs_recreation()` is entirely
**label-based** — it does not diff the container's env. If a new setting affects the container's
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
and compare it there, or the change will silently not take effect until some unrelated setting
forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`.
- **New model fields need an explicit serde default when the correct default isn't the zero value.**
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
`models/project.rs` for anything that should default to true.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
## Testing
@@ -43,7 +43,7 @@ use std::time::Duration;
use futures_util::StreamExt;
use tauri::{AppHandle, Emitter, State};
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, Mutex};
use tokio::sync::{mpsc, oneshot, Mutex};
use crate::docker::container::is_container_running;
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
@@ -394,6 +394,18 @@ fn pending_input() -> &'static Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>> {
PENDING_INPUT.get_or_init(|| Mutex::new(None))
}
/// Abort channel for the in-flight flow, claimed and released in lockstep with
/// [`PENDING_INPUT`].
///
/// Without this the only exits are "finished" and "timed out", so a user who
/// closes the dialog would be locked out by the single-flight guard until
/// `SETUP_TIMEOUT` elapsed.
static CANCEL_TX: OnceLock<Mutex<Option<oneshot::Sender<()>>>> = OnceLock::new();
fn cancel_slot() -> &'static Mutex<Option<oneshot::Sender<()>>> {
CANCEL_TX.get_or_init(|| Mutex::new(None))
}
fn emit_progress(app: &AppHandle, project_id: &str, message: &str) {
let _ = app.emit(
PROGRESS_EVENT,
@@ -431,6 +443,7 @@ async fn run_setup_token(
project_id: &str,
container_id: &str,
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
mut cancel_rx: oneshot::Receiver<()>,
) -> Result<String, String> {
// A pty (`tty = true`) because `setup-token` renders an interactive TUI and
// reads the pasted code in raw mode, which a plain pipe cannot provide.
@@ -460,6 +473,14 @@ async fn run_setup_token(
// alive for the whole session anyway — dropping it early would tear the
// output stream down with it.
let next = tokio::select! {
// Cancellation wins the race so a user who gives up isn't held by
// the single-flight guard until the timeout. Dropping `input` and
// `output` on return tears the exec down with them.
_ = &mut cancel_rx => {
return Err(
"Authentication cancelled. No token was stored.".to_string()
);
}
Some(data) = input_rx.recv() => {
if let Err(e) = input.write_all(&data).await {
return Err(format!(
@@ -569,7 +590,11 @@ pub async fn acquire_claude_token(
// Claim the flow before touching anything else, so a second caller bounces
// off the guard rather than half-configuring the same project.
let (input_tx, input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
{
// Both slots are claimed under the input lock held first, and released
// in the same order below, so the guard and its abort channel can never
// disagree about whether a flow is live.
let mut slot = pending_input().lock().await;
if slot.is_some() {
return Err(
@@ -578,6 +603,7 @@ pub async fn acquire_claude_token(
);
}
*slot = Some(input_tx);
*cancel_slot().lock().await = Some(cancel_tx);
}
let bridge_was_enabled = project.auth_bridge_enabled;
@@ -616,13 +642,14 @@ pub async fn acquire_claude_token(
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
);
run_setup_token(&app_handle, &project_id, &container_id, input_rx).await
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await
}
.await;
// Release the flow, then restore the bridge — both unconditionally, so a
// failed or cancelled login leaves nothing latched on.
*pending_input().lock().await = None;
*cancel_slot().lock().await = None;
if !bridge_was_enabled {
// Stop the poller first: it awaits teardown, so host ports are provably
// released before the flag goes back.
@@ -686,6 +713,22 @@ pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
.map_err(|_| "The authentication flow has already ended.".to_string())
}
/// Abort an in-flight [`acquire_claude_token`].
///
/// Tears the `setup-token` exec down and releases the single-flight guard, so
/// the user can immediately try again rather than waiting out `SETUP_TIMEOUT`.
/// A no-op when nothing is running, so closing the dialog twice is harmless.
#[tauri::command]
pub async fn cancel_claude_token() -> Result<(), String> {
let Some(sender) = cancel_slot().lock().await.take() else {
return Ok(());
};
// `Err` only means the flow finished between the take and the send, which
// is exactly the outcome cancelling wanted.
let _ = sender.send(());
Ok(())
}
/// Whether a shared Claude token exists. Deliberately a boolean — no command
/// here ever hands the token itself to the frontend.
#[tauri::command]
+1
View File
@@ -168,6 +168,7 @@ pub fn run() {
// Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code,
commands::auth_token_commands::cancel_claude_token,
commands::auth_token_commands::has_claude_token,
commands::auth_token_commands::clear_claude_token,
// Settings
@@ -19,6 +19,8 @@ const baseProject: Project = {
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,
@@ -47,6 +47,8 @@ const baseProject: Project = {
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,
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ModelSection from "./ModelSection";
import type { Backend, Project } from "../../../../lib/types";
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 TOGGLE = "Use the shared Claude token";
const save = vi.fn().mockResolvedValue(true);
function renderSection(over: Partial<Project> = {}, disabled = false) {
return render(
<ModelSection
project={{ ...baseProject, ...over }}
save={save}
disabled={disabled}
/>,
);
}
describe("ModelSection — shared auth token toggle", () => {
beforeEach(() => vi.clearAllMocks());
it("renders for the Anthropic backend", () => {
renderSection();
expect(screen.getByRole("switch", { name: TOGGLE })).toBeInTheDocument();
});
it.each<Backend>(["bedrock", "ollama", "open_ai_compatible"])(
"is hidden for the %s backend",
(backend) => {
renderSection({ backend });
expect(screen.queryByRole("switch", { name: TOGGLE })).not.toBeInTheDocument();
},
);
it("defaults to on, including for data written before the field existed", () => {
renderSection();
expect(screen.getByRole("switch", { name: TOGGLE })).toHaveAttribute(
"aria-checked",
"true",
);
const legacy = { ...baseProject } as Partial<Project>;
delete legacy.use_shared_auth_token;
renderSection(legacy);
expect(screen.getAllByRole("switch", { name: TOGGLE })[1]).toHaveAttribute(
"aria-checked",
"true",
);
});
it("saves the opt-out and explains the consequence", () => {
renderSection();
fireEvent.click(screen.getByRole("switch", { name: TOGGLE }));
expect(save).toHaveBeenCalledWith({ use_shared_auth_token: false });
renderSection({ use_shared_auth_token: false });
expect(screen.getByText(/needs its own `claude login`/)).toBeInTheDocument();
});
it("follows the container-stopped rule like the rest of the group", () => {
renderSection({}, true);
expect(screen.getByRole("switch", { name: TOGGLE })).toBeDisabled();
});
});
@@ -7,7 +7,13 @@ import type {
OpenAiCompatibleConfig,
Project,
} from "../../../../lib/types";
import Field, { ConfigGroup, monoInputClass, selectClass } from "../../../ui/Field";
import Field, {
ConfigGroup,
SwitchRow,
monoInputClass,
selectClass,
} from "../../../ui/Field";
import Toggle from "../../../ui/Toggle";
export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
auth_method: "static_credentials",
@@ -106,6 +112,10 @@ export default function ModelSection({ project, save, disabled }: Props) {
},
});
// Defaults to on: projects created before the field existed, and any data
// that predates it, should still pick the shared token up.
const useSharedToken = project.use_shared_auth_token !== false;
const handleBackendChange = (mode: Backend) => {
const patch: Partial<Project> = { backend: mode };
if (mode === "bedrock" && !project.bedrock_config)
@@ -139,6 +149,29 @@ export default function ModelSection({ project, save, disabled }: Props) {
)}
</Field>
{/* Only Anthropic reads CLAUDE_CODE_OAUTH_TOKEN; the other backends
authenticate through their own credentials entirely. */}
{project.backend === "anthropic" && (
<div className="pt-2 border-t border-[var(--border-color)]">
<SwitchRow
label="Use the shared Claude token"
hint={
useSharedToken
? "Signs in with the shared token from Settings → Claude Authentication, so this container needs no `claude login` of its own."
: "This project is opted out: it ignores the shared token and needs its own `claude login` inside the container."
}
control={
<Toggle
label="Use the shared Claude token"
checked={useSharedToken}
onChange={(value) => save({ use_shared_auth_token: value })}
disabled={disabled}
/>
}
/>
</div>
)}
{project.backend === "bedrock" && (
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
<Field label="Authentication method" hint="How the container proves its identity to Bedrock.">
@@ -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>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth";
describe("extractSignInUrl", () => {
it("finds the authorize URL in realistic setup-token output", () => {
const url =
"https://claude.ai/oauth/authorize?code=true&client_id=abc&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback";
expect(
extractSignInUrl(
`Claude Code long-lived token setup\nBrowser didn't open? Use this url to sign in:\n${url}\n\nPaste code here if prompted > `,
),
).toBe(url);
});
it("returns null before the CLI has printed anything useful", () => {
expect(extractSignInUrl("")).toBeNull();
expect(extractSignInUrl("Starting `claude setup-token`…\n")).toBeNull();
});
it("drops trailing prose punctuation", () => {
expect(extractSignInUrl("Visit https://claude.ai/oauth/authorize?x=1.")).toBe(
"https://claude.ai/oauth/authorize?x=1",
);
});
it("prefers the OAuth URL over unrelated links in the transcript", () => {
const text =
"Docs: https://docs.claude.com/en/docs/claude-code/setup-token-and-more-words\n" +
"Sign in: https://claude.ai/oauth/authorize?code=true\n";
expect(extractSignInUrl(text)).toBe("https://claude.ai/oauth/authorize?code=true");
});
it("keeps the full link when a TUI repaint also emitted a truncated one", () => {
const full = "https://claude.ai/oauth/authorize?code=true&client_id=abcdefgh";
const text = `https://claude.ai/oauth/authorize?code=tr\n${full}\n`;
expect(extractSignInUrl(text)).toBe(full);
});
});
describe("authErrorMessage", () => {
it("passes a Tauri string rejection through verbatim", () => {
const backend =
"The container for 'api' is not running. Start it, then run authentication again.";
expect(authErrorMessage(backend, "fallback")).toBe(backend);
});
it("uses an Error's message", () => {
expect(authErrorMessage(new Error("channel closed"), "fallback")).toBe(
"channel closed",
);
});
it("falls back rather than stringifying an opaque value", () => {
expect(authErrorMessage({ weird: true }, "Something went wrong.")).toBe(
"Something went wrong.",
);
expect(authErrorMessage(" ", "Something went wrong.")).toBe(
"Something went wrong.",
);
});
});
+258
View File
@@ -0,0 +1,258 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as commands from "../lib/tauri-commands";
import type {
ClaudeTokenOutputEvent,
ClaudeTokenProgressEvent,
} from "../lib/types";
/**
* Front-end half of the shared Claude Code token flow.
*
* The token itself never crosses the IPC boundary — `has_claude_token` returns
* a boolean and the streamed output is redacted backend-side. Nothing in here
* stores, parses, or renders a credential; the transcript is displayed as-is
* precisely because it has already been scrubbed.
*/
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
const PROGRESS_EVENT = "claude-token-progress";
const OUTPUT_EVENT = "claude-token-output";
/** Bound on the retained transcript. The tail is the interesting part. */
const MAX_OUTPUT = 64 * 1024;
/**
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
* backend writes its errors as complete, actionable sentences ("The container
* for 'x' is not running. Start it, then run authentication again."). So use
* them verbatim rather than stringifying an opaque value, and only synthesise
* a message when the rejection is something else — a thrown `Error`, or an IPC
* channel that died without one.
*/
export function authErrorMessage(e: unknown, fallback: string): string {
if (typeof e === "string" && e.trim()) return e.trim();
if (e instanceof Error && e.message.trim()) return e.message.trim();
return fallback;
}
/**
* Pick the sign-in URL out of `claude setup-token`'s transcript.
*
* Prefers an OAuth-looking URL, and among candidates prefers the longest: a
* TUI repaints, and a repaint can land a truncated copy of the same URL in the
* transcript. Longest-wins means a partial frame never replaces the full link.
*/
export function extractSignInUrl(text: string): string | null {
const matches = text.match(/https?:\/\/[^\s"'<>`]+/g);
if (!matches) return null;
const cleaned = matches
// Trailing punctuation belongs to the prose, not the URL.
.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, ""))
.filter((url) => url.length > "https://".length);
const oauth = cleaned.filter((url) => /oauth|authorize|login/i.test(url));
const pool = oauth.length > 0 ? oauth : cleaned;
let best: string | null = null;
for (const url of pool) {
if (best === null || url.length >= best.length) best = url;
}
return best;
}
// ─────────────────────────────────────────────────────────────────────────────
// Token presence
// ─────────────────────────────────────────────────────────────────────────────
export type ClaudeTokenStatus = "checking" | "stored" | "absent" | "unavailable";
/** Whether a shared token exists, plus a way to re-check after a change. */
export function useClaudeTokenStatus() {
const [status, setStatus] = useState<ClaudeTokenStatus>("checking");
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const present = await commands.hasClaudeToken();
setStatus(present ? "stored" : "absent");
setError(null);
} catch (e) {
setStatus("unavailable");
setError(
authErrorMessage(
e,
"Could not read the OS keychain, so whether a shared token exists is unknown.",
),
);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return { status, error, refresh };
}
// ─────────────────────────────────────────────────────────────────────────────
// Acquisition
// ─────────────────────────────────────────────────────────────────────────────
export type AcquisitionPhase = "running" | "succeeded" | "failed";
export interface ClaudeTokenAcquisition {
phase: AcquisitionPhase;
/** Milestone messages from `claude-token-progress`, oldest first. */
progress: string[];
/** Redacted transcript from `claude-token-output`. */
output: string;
signInUrl: string | null;
/** Set when the flow ends badly; always a full sentence the user can act on. */
error: string | null;
submitting: boolean;
codeSubmitted: boolean;
submitError: string | null;
submitCode: (code: string) => Promise<boolean>;
}
/**
* Runs one `acquire_claude_token` flow for the lifetime of the calling
* component. Starts on mount, so mount this only when the user has asked for
* it — the backend allows a single flow at a time.
*
* `onSucceeded` fires once, after the token has been stored.
*/
export function useClaudeTokenAcquisition(
projectId: string,
onSucceeded?: () => void,
): ClaudeTokenAcquisition {
const [phase, setPhase] = useState<AcquisitionPhase>("running");
const [progress, setProgress] = useState<string[]>([]);
const [output, setOutput] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [codeSubmitted, setCodeSubmitted] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Held in a ref so a fresh callback identity cannot restart the flow.
const succeededRef = useRef(onSucceeded);
succeededRef.current = onSucceeded;
useEffect(() => {
let cancelled = false;
const unlisteners: UnlistenFn[] = [];
const register = async <T,>(name: string, handle: (payload: T) => void) => {
const unlisten = await listen<T>(name, (event) => handle(event.payload));
// Registration is async: if the component went away while we were
// awaiting, drop the listener now rather than leaking it.
if (cancelled) {
unlisten();
return;
}
unlisteners.push(unlisten);
};
void (async () => {
try {
await register<ClaudeTokenProgressEvent>(PROGRESS_EVENT, (payload) => {
if (payload.project_id !== projectId) return;
setProgress((prev) =>
prev[prev.length - 1] === payload.message
? prev
: [...prev, payload.message],
);
});
await register<ClaudeTokenOutputEvent>(OUTPUT_EVENT, (payload) => {
if (payload.project_id !== projectId) return;
setOutput((prev) => {
const next = prev + payload.chunk;
return next.length > MAX_OUTPUT
? next.slice(next.length - MAX_OUTPUT)
: next;
});
});
} catch (e) {
if (cancelled) return;
setPhase("failed");
setError(
authErrorMessage(
e,
"Could not subscribe to the authentication events, so the flow was not started. Restart Triple-C and try again.",
),
);
return;
}
if (cancelled) return;
try {
await commands.acquireClaudeToken(projectId);
if (cancelled) return;
setPhase("succeeded");
succeededRef.current?.();
} catch (e) {
if (cancelled) return;
setPhase("failed");
setError(
authErrorMessage(
e,
"`claude setup-token` did not finish. No token was stored — try again.",
),
);
}
})();
return () => {
cancelled = true;
for (const unlisten of unlisteners) {
try {
unlisten();
} catch {
// Nothing useful to do while tearing down.
}
}
};
}, [projectId]);
const submitCode = useCallback(async (code: string) => {
const trimmed = code.trim();
if (!trimmed) {
setSubmitError("Enter the code shown after signing in.");
return false;
}
setSubmitting(true);
setSubmitError(null);
try {
await commands.submitClaudeTokenCode(trimmed);
setCodeSubmitted(true);
return true;
} catch (e) {
setSubmitError(
authErrorMessage(
e,
"Could not deliver the code to `claude setup-token`. Copy it again and retry.",
),
);
return false;
} finally {
setSubmitting(false);
}
}, []);
const signInUrl = useMemo(() => extractSignInUrl(output), [output]);
return {
phase,
progress,
output,
signInUrl,
error,
submitting,
codeSubmitted,
submitError,
submitCode,
};
}
+2
View File
@@ -158,5 +158,7 @@ export const acquireClaudeToken = (projectId: string) =>
invoke<void>("acquire_claude_token", { projectId });
export const submitClaudeTokenCode = (code: string) =>
invoke<void>("submit_claude_token_code", { code });
/** Abort an in-flight acquisition and release the single-flight guard. No-op if nothing is running. */
export const cancelClaudeToken = () => invoke<void>("cancel_claude_token");
export const hasClaudeToken = () => invoke<boolean>("has_claude_token");
export const clearClaudeToken = () => invoke<void>("clear_claude_token");