Drop MCP management; add permission modes, Project Home, Auth Bridge and shared auth #13
@@ -56,22 +56,56 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
|||||||
|
|
||||||
### Frontend Structure (`app/src/`)
|
### 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`)
|
- **`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
|
- **`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/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||||
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
|
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
|
||||||
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
|
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
|
||||||
- **`components/settings/`** — Settings panels for API keys, Docker, AWS, Web Terminal
|
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/`)
|
### 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:
|
- **`docker/`** — Docker API layer using bollard:
|
||||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
- `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
|
- `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:
|
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
|
||||||
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
|
- `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
|
- `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
|
### 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
|
### Authentication
|
||||||
|
|
||||||
@@ -108,8 +148,18 @@ Per-project, independently configured:
|
|||||||
|
|
||||||
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
|
- 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 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`.
|
- 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
|
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ use std::time::Duration;
|
|||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
use tokio::io::AsyncWriteExt;
|
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::container::is_container_running;
|
||||||
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
|
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))
|
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) {
|
fn emit_progress(app: &AppHandle, project_id: &str, message: &str) {
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
PROGRESS_EVENT,
|
PROGRESS_EVENT,
|
||||||
@@ -431,6 +443,7 @@ async fn run_setup_token(
|
|||||||
project_id: &str,
|
project_id: &str,
|
||||||
container_id: &str,
|
container_id: &str,
|
||||||
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||||
|
mut cancel_rx: oneshot::Receiver<()>,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
// A pty (`tty = true`) because `setup-token` renders an interactive TUI and
|
// 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.
|
// 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
|
// alive for the whole session anyway — dropping it early would tear the
|
||||||
// output stream down with it.
|
// output stream down with it.
|
||||||
let next = tokio::select! {
|
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() => {
|
Some(data) = input_rx.recv() => {
|
||||||
if let Err(e) = input.write_all(&data).await {
|
if let Err(e) = input.write_all(&data).await {
|
||||||
return Err(format!(
|
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
|
// Claim the flow before touching anything else, so a second caller bounces
|
||||||
// off the guard rather than half-configuring the same project.
|
// off the guard rather than half-configuring the same project.
|
||||||
let (input_tx, input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
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;
|
let mut slot = pending_input().lock().await;
|
||||||
if slot.is_some() {
|
if slot.is_some() {
|
||||||
return Err(
|
return Err(
|
||||||
@@ -578,6 +603,7 @@ pub async fn acquire_claude_token(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
*slot = Some(input_tx);
|
*slot = Some(input_tx);
|
||||||
|
*cancel_slot().lock().await = Some(cancel_tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
let bridge_was_enabled = project.auth_bridge_enabled;
|
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.",
|
"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;
|
.await;
|
||||||
|
|
||||||
// Release the flow, then restore the bridge — both unconditionally, so a
|
// Release the flow, then restore the bridge — both unconditionally, so a
|
||||||
// failed or cancelled login leaves nothing latched on.
|
// failed or cancelled login leaves nothing latched on.
|
||||||
*pending_input().lock().await = None;
|
*pending_input().lock().await = None;
|
||||||
|
*cancel_slot().lock().await = None;
|
||||||
if !bridge_was_enabled {
|
if !bridge_was_enabled {
|
||||||
// Stop the poller first: it awaits teardown, so host ports are provably
|
// Stop the poller first: it awaits teardown, so host ports are provably
|
||||||
// released before the flag goes back.
|
// 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())
|
.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
|
/// Whether a shared Claude token exists. Deliberately a boolean — no command
|
||||||
/// here ever hands the token itself to the frontend.
|
/// here ever hands the token itself to the frontend.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ pub fn run() {
|
|||||||
// Shared Claude Code auth token
|
// Shared Claude Code auth token
|
||||||
commands::auth_token_commands::acquire_claude_token,
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
commands::auth_token_commands::submit_claude_token_code,
|
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::has_claude_token,
|
||||||
commands::auth_token_commands::clear_claude_token,
|
commands::auth_token_commands::clear_claude_token,
|
||||||
// Settings
|
// Settings
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const baseProject: Project = {
|
|||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
sandbox_mode_enabled: true,
|
sandbox_mode_enabled: true,
|
||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
use_shared_auth_token: true,
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
permission_mode: null,
|
permission_mode: null,
|
||||||
ssh_key_path: null,
|
ssh_key_path: null,
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const baseProject: Project = {
|
|||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
sandbox_mode_enabled: true,
|
sandbox_mode_enabled: true,
|
||||||
mission_control_enabled: false,
|
mission_control_enabled: false,
|
||||||
|
auth_bridge_enabled: false,
|
||||||
|
use_shared_auth_token: true,
|
||||||
full_permissions: false,
|
full_permissions: false,
|
||||||
permission_mode: null,
|
permission_mode: null,
|
||||||
ssh_key_path: 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,
|
OpenAiCompatibleConfig,
|
||||||
Project,
|
Project,
|
||||||
} from "../../../../lib/types";
|
} 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 = {
|
export const DEFAULT_BEDROCK_CONFIG: BedrockConfig = {
|
||||||
auth_method: "static_credentials",
|
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 handleBackendChange = (mode: Backend) => {
|
||||||
const patch: Partial<Project> = { backend: mode };
|
const patch: Partial<Project> = { backend: mode };
|
||||||
if (mode === "bedrock" && !project.bedrock_config)
|
if (mode === "bedrock" && !project.bedrock_config)
|
||||||
@@ -139,6 +149,29 @@ export default function ModelSection({ project, save, disabled }: Props) {
|
|||||||
)}
|
)}
|
||||||
</Field>
|
</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" && (
|
{project.backend === "bedrock" && (
|
||||||
<div className="space-y-4 pt-2 border-t border-[var(--border-color)]">
|
<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.">
|
<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>’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 — 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… 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…
|
||||||
|
</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 Toggle from "../ui/Toggle";
|
||||||
import WebTerminalSettings from "./WebTerminalSettings";
|
import WebTerminalSettings from "./WebTerminalSettings";
|
||||||
import SttSettings from "./SttSettings";
|
import SttSettings from "./SttSettings";
|
||||||
|
import SharedAuthSettings from "./SharedAuthSettings";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
@@ -149,6 +150,10 @@ export default function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</AccordionSection>
|
</AccordionSection>
|
||||||
|
|
||||||
|
<AccordionSection id="claude-auth" title="Claude Authentication" defaultOpen={false}>
|
||||||
|
<SharedAuthSettings />
|
||||||
|
</AccordionSection>
|
||||||
|
|
||||||
<AccordionSection id="backends" title="Backends" defaultOpen={false}>
|
<AccordionSection id="backends" title="Backends" defaultOpen={false}>
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
<div className="pt-3 border-t border-[var(--border-color)]" />
|
<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 — 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>’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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -158,5 +158,7 @@ export const acquireClaudeToken = (projectId: string) =>
|
|||||||
invoke<void>("acquire_claude_token", { projectId });
|
invoke<void>("acquire_claude_token", { projectId });
|
||||||
export const submitClaudeTokenCode = (code: string) =>
|
export const submitClaudeTokenCode = (code: string) =>
|
||||||
invoke<void>("submit_claude_token_code", { code });
|
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 hasClaudeToken = () => invoke<boolean>("has_claude_token");
|
||||||
export const clearClaudeToken = () => invoke<void>("clear_claude_token");
|
export const clearClaudeToken = () => invoke<void>("clear_claude_token");
|
||||||
|
|||||||
Reference in New Issue
Block a user