Add shared-auth-token UI and make cancelling actually cancel
UI for the shared Claude token: a Settings section showing token state with Authenticate and Revoke, an acquisition modal built on the shared Modal (sign-in link handed to the host browser via the opener plugin, plus the code input that answers `setup-token`'s stdin prompt — the flow cannot complete without it), and a per-project opt-out toggle shown only for the Anthropic backend. Cancellation: acquire_claude_token previously had only two exits, completion and a 15-minute timeout, and held the single-flight guard for the whole time. Closing the dialog therefore locked the user out of retrying for up to 15 minutes. Adds cancel_claude_token, backed by a oneshot claimed and released in lockstep with the input guard, selected on in the run loop so it wins the race and tears the exec down. The dialog's Cancel now calls it and closes either way. Also refreshes CLAUDE.md, which had drifted: it documented the deleted ProjectCard, and asserted that new IPC commands need permission grants in capabilities/default.json — they do not, that file covers plugin commands only. Adds the conventions that would otherwise bite: container_needs_recreation() is purely label-based and never diffs env, so container-affecting state needs its own label; and #[serde(default)] on a bool yields false regardless of intent. Corrects the claim that Reset preserves credentials. Reset calls remove_project_volumes, which deletes both the home and claude-config volumes, so it wipes ~/.claude, the OAuth token, installed skills and session transcripts. 84 frontend tests, 34 Rust tests, both builds clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user