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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user