diff --git a/CLAUDE.md b/CLAUDE.md index 0fc7db7..f6a569b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:` and + `home:`; `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 diff --git a/app/src-tauri/src/commands/auth_token_commands.rs b/app/src-tauri/src/commands/auth_token_commands.rs index d2a699b..eb42463 100644 --- a/app/src-tauri/src/commands/auth_token_commands.rs +++ b/app/src-tauri/src/commands/auth_token_commands.rs @@ -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>>> { 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>>> = OnceLock::new(); + +fn cancel_slot() -> &'static Mutex>> { + 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>, + mut cancel_rx: oneshot::Receiver<()>, ) -> Result { // 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::>(); + 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] diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 4ad063f..bcc6655 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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 diff --git a/app/src/components/projects/PermissionModeControl.test.tsx b/app/src/components/projects/PermissionModeControl.test.tsx index 5050c15..84f115e 100644 --- a/app/src/components/projects/PermissionModeControl.test.tsx +++ b/app/src/components/projects/PermissionModeControl.test.tsx @@ -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, diff --git a/app/src/components/projects/ProjectRow.test.tsx b/app/src/components/projects/ProjectRow.test.tsx index ba44285..d22064c 100644 --- a/app/src/components/projects/ProjectRow.test.tsx +++ b/app/src/components/projects/ProjectRow.test.tsx @@ -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, diff --git a/app/src/components/projects/home/config/ModelSection.test.tsx b/app/src/components/projects/home/config/ModelSection.test.tsx new file mode 100644 index 0000000..ec7c2b2 --- /dev/null +++ b/app/src/components/projects/home/config/ModelSection.test.tsx @@ -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 = {}, disabled = false) { + return render( + , + ); +} + +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(["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; + 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(); + }); +}); diff --git a/app/src/components/projects/home/config/ModelSection.tsx b/app/src/components/projects/home/config/ModelSection.tsx index 39774cd..9ffc271 100644 --- a/app/src/components/projects/home/config/ModelSection.tsx +++ b/app/src/components/projects/home/config/ModelSection.tsx @@ -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 = { backend: mode }; if (mode === "bedrock" && !project.bedrock_config) @@ -139,6 +149,29 @@ export default function ModelSection({ project, save, disabled }: Props) { )} + {/* Only Anthropic reads CLAUDE_CODE_OAUTH_TOKEN; the other backends + authenticate through their own credentials entirely. */} + {project.backend === "anthropic" && ( +
+ save({ use_shared_auth_token: value })} + disabled={disabled} + /> + } + /> +
+ )} + {project.backend === "bedrock" && (
diff --git a/app/src/components/settings/ClaudeAuthModal.test.tsx b/app/src/components/settings/ClaudeAuthModal.test.tsx new file mode 100644 index 0000000..7487c1a --- /dev/null +++ b/app/src/components/settings/ClaudeAuthModal.test.tsx @@ -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 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( + , + ); +} + +/** 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)); + }); +}); diff --git a/app/src/components/settings/ClaudeAuthModal.tsx b/app/src/components/settings/ClaudeAuthModal.tsx new file mode 100644 index 0000000..13f5be1 --- /dev/null +++ b/app/src/components/settings/ClaudeAuthModal.tsx @@ -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 = { + 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(null); + const [confirmCancel, setConfirmCancel] = useState(false); + const codeRef = useRef(null); + const outputRef = useRef(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 ( + + Running claude setup-token in{" "} + {projectName}’s + container. The token it produces is shared by every project. + + } + widthClassName="w-[40rem]" + dismissible={!running} + onClose={onClose} + initialFocusRef={codeRef} + footer={ + confirmCancel ? ( + <> + + + + ) : running ? ( + + ) : ( + + ) + } + > +
+
+ + {latestProgress && ( +

+ {latestProgress} +

+ )} +
+ + {/* Step 1 — sign in. */} +
+

+ 1. Sign in with Anthropic +

+ {flow.signInUrl ? ( + + ) : ( +

+ Waiting for claude setup-token to print + the sign-in link… It appears in the output below as soon as the CLI + starts. +

+ )} + {linkError && ( +

{linkError}

+ )} +
+ + {/* Step 2 — the code. Without this the CLI sits on its stdin prompt forever. */} +
+

+ 2. Paste the code +

+
+
+ 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 && ( +

{flow.submitError}

+ )} + {!flow.submitError && flow.codeSubmitted && running && ( +

+ Code sent. Waiting for setup-token{" "} + to finish… +

+ )} +
+ +
+
+ + {/* Step 3 — outcome. */} + {flow.phase === "succeeded" && ( +

+ Token stored in the OS keychain. Restart your Anthropic-backend containers + to start using it. +

+ )} + {flow.phase === "failed" && flow.error && ( +

+ {flow.error} +

+ )} + {confirmCancel && ( +

+ This stops claude setup-token inside the + container and discards the sign-in. No token is stored. You can start again + straight away. +

+ )} + + {/* Redacted backend-side before it is emitted; still never parsed here. */} +
+

+ Command output +

+
+            {flow.output || "Starting `claude setup-token`…\n"}
+          
+
+
+
+ ); +} diff --git a/app/src/components/settings/SettingsPanel.tsx b/app/src/components/settings/SettingsPanel.tsx index 66c9a7a..0d6ecb1 100644 --- a/app/src/components/settings/SettingsPanel.tsx +++ b/app/src/components/settings/SettingsPanel.tsx @@ -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() {
+ + + +
diff --git a/app/src/components/settings/SharedAuthSettings.test.tsx b/app/src/components/settings/SharedAuthSettings.test.tsx new file mode 100644 index 0000000..83dc956 --- /dev/null +++ b/app/src/components/settings/SharedAuthSettings.test.tsx @@ -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 => ({ + ...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(); + + 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(); + expect(screen.getByRole("button", { name: "Authenticate" })).toBeDisabled(); + await waitFor(() => expect(hasClaudeToken).toHaveBeenCalled()); + }); + + it("enables Authenticate once a container is running", async () => { + projects = [running()]; + render(); + + 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(); + expect(screen.queryByLabelText("Run the sign-in in")).not.toBeInTheDocument(); + + projects = [running(), running({ id: "p2", name: "web" })]; + rerender(); + 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(); + + 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(); + + await screen.findByText("keyring backend unavailable"); + expect(screen.queryByRole("button", { name: "Revoke" })).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/components/settings/SharedAuthSettings.tsx b/app/src/components/settings/SharedAuthSettings.tsx new file mode 100644 index 0000000..99d33c3 --- /dev/null +++ b/app/src/components/settings/SharedAuthSettings.tsx @@ -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(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 ( +
+
+
+ + Shared Claude authentication + + +
+

+ Authenticate once and every project on the Anthropic backend signs in with + that token, instead of each container running its own{" "} + claude login. The token is held in your OS + keychain and injected into containers as an environment variable. +

+

+ {display.detail} +

+ {error &&

{error}

} +
+ + {runnable.length > 1 && ( +
+ + +
+ )} + +
+ + {status === "stored" && ( + + )} +
+ + {!host && ( +

+ No project is running. Signing in runs{" "} + claude setup-token inside a container, so + start a project first — any one will do, it only lends its container. +

+ )} + + {host && ( +

+ The sign-in runs in{" "} + {host.name}’s + container, but the resulting token is shared by all projects. +

+ )} + + {authOpen && host && ( + setAuthOpen(false)} + onAuthenticated={() => { + void refresh(); + }} + /> + )} + + {confirmRevoke && ( + setConfirmRevoke(false)} + footer={ + <> + + + + } + > +

+ This deletes the shared token from your OS keychain. Anthropic-backend + projects fall back to their own{" "} + claude login the next time their + container starts. Existing running containers keep working until they are + restarted. +

+
+ )} +
+ ); +} diff --git a/app/src/hooks/useClaudeAuth.test.ts b/app/src/hooks/useClaudeAuth.test.ts new file mode 100644 index 0000000..c8cfe8f --- /dev/null +++ b/app/src/hooks/useClaudeAuth.test.ts @@ -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.", + ); + }); +}); diff --git a/app/src/hooks/useClaudeAuth.ts b/app/src/hooks/useClaudeAuth.ts new file mode 100644 index 0000000..238dab5 --- /dev/null +++ b/app/src/hooks/useClaudeAuth.ts @@ -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("checking"); + const [error, setError] = useState(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; +} + +/** + * 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("running"); + const [progress, setProgress] = useState([]); + const [output, setOutput] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [codeSubmitted, setCodeSubmitted] = useState(false); + const [submitError, setSubmitError] = useState(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 (name: string, handle: (payload: T) => void) => { + const unlisten = await listen(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(PROGRESS_EVENT, (payload) => { + if (payload.project_id !== projectId) return; + setProgress((prev) => + prev[prev.length - 1] === payload.message + ? prev + : [...prev, payload.message], + ); + }); + await register(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, + }; +} diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 6d5f9b2..ce08631 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -158,5 +158,7 @@ export const acquireClaudeToken = (projectId: string) => invoke("acquire_claude_token", { projectId }); export const submitClaudeTokenCode = (code: string) => invoke("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("cancel_claude_token"); export const hasClaudeToken = () => invoke("has_claude_token"); export const clearClaudeToken = () => invoke("clear_claude_token");