Files
handler/frontend/components/sections/LoginSection.tsx
T
Claude 07d8c3aa19 Turn the Claude Login page into a full Claude management page
The dashboard's Claude page now manages the whole Claude Code install agents
run on, not just the account login:

- Skills: operator-authored SKILL.md rows, synced to each worker's user-level
  ~/.claude/skills at every launch. Managed dirs carry a .handler-managed
  marker so deletions in the UI propagate while hand-installed skills survive.
- Connectors: MCP servers (stdio/http/sse) written per-launch as
  .claude/mcp-servers.json and passed to claude via --mcp-config, so nothing
  lands in the managed repo's tracked tree.
- Plugins: marketplace-pinned plugins folded into generated settings as
  extraKnownMarketplaces + enabledPlugins, installing on boot of headless runs.
- Permissions: defaultMode override plus allow/deny/ask rules merged over the
  env baseline into every generated settings.json.

All of it is plain DB state (new claude_skills / claude_connectors /
claude_plugins / claude_config tables, migration 0010) edited through the new
admin-gated /claude/* API routes and applied by the control container at spawn
and resume — changes reach the next launch of every agent with no redeploy.

The login flow moved into the page's Account tab unchanged; /login redirects
to /claude for old bookmarks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f42XmjtVsc3zQ9Dhn6DqZ
2026-07-23 13:32:41 +00:00

151 lines
5.6 KiB
TypeScript

/* Claude account login panel (the Account tab of the Claude page) — drives the bundled
* `claude /login` OAuth flow on the host from the web UI.
*
* Click "Log in to Claude" → a small OAuth-style popup window opens (like "Sign in with
* Google") and the worker drives `claude /login` in the control container, selecting the
* subscription account and returning the claude.com authorization URL, which we point the
* popup at. (claude.com refuses to be embedded in an iframe, so a popup — not an inline
* frame — is the right surface.) You authorize there, copy the code, and paste it back to
* finish. All state lives in the store's `claudeLogin` machine (login_start / login_submit
* commands). */
"use client";
import { useEffect, useRef, useState } from "react";
import { useDashboard } from "@/components/store";
import { Button, Callout, Input } from "@/components/ui";
function openLoginPopup(url: string): Window | null {
const w = 520;
const h = 760;
// Center over the current window; specifying a size makes browsers open a popup window
// (the "Sign in with …" surface) rather than a new tab.
const left = window.screenX + Math.max(0, (window.outerWidth - w) / 2);
const top = window.screenY + Math.max(0, (window.outerHeight - h) / 2);
return window.open(
url,
"claude-login",
`popup=yes,width=${w},height=${h},left=${Math.round(left)},top=${Math.round(top)}`,
);
}
export function ClaudeLoginPanel() {
const s = useDashboard();
const { status, url, message } = s.claudeLogin;
const [code, setCode] = useState("");
const popupRef = useRef<Window | null>(null);
const busy = status === "starting" || status === "submitting";
const awaiting = status === "awaiting" || status === "submitting";
// Open a blank popup *within the click* (below) so browsers don't block it; once
// login_start returns the URL, navigate that same popup to it.
useEffect(() => {
if (status === "awaiting" && url && popupRef.current && !popupRef.current.closed) {
try {
popupRef.current.location.href = url;
} catch {
/* cross-origin after navigation — expected, ignore */
}
}
if (status === "done" || status === "error") {
popupRef.current?.close();
popupRef.current = null;
}
}, [status, url]);
const start = () => {
// Open the popup now, on the user gesture, to a lightweight loading page; the effect
// above redirects it to the real URL when it arrives.
popupRef.current = openLoginPopup("about:blank");
void s.startClaudeLogin();
};
const submit = async () => {
const ok = await s.submitClaudeCode(code);
if (ok) setCode("");
};
return (
<>
<div className="faint" style={{ fontSize: "var(--text-sm)" }}>
Log Claude Code in on the host so agents can run. This drives{" "}
<span className="mono">claude /login</span> in the control container and picks the
Claude account with a subscription.
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16, marginTop: 14 }}>
{message && (
<Callout tone={status === "error" ? "danger" : status === "done" ? "success" : "info"}>
{message}
</Callout>
)}
{status === "done" ? (
<div>
<Button variant="secondary" onClick={s.resetClaudeLogin}>
Log in again
</Button>
</div>
) : !awaiting ? (
<div className="hstack" style={{ gap: 10 }}>
<Button variant="primary" disabled={busy} onClick={start}>
{status === "starting" ? "Starting…" : "Log in to Claude"}
</Button>
{status === "error" && (
<Button variant="ghost" disabled={busy} onClick={start}>
Retry
</Button>
)}
</div>
) : (
<>
<Callout tone="info">
A Claude sign-in window should have opened. Authorize there, copy the code
Claude shows you, and paste it below. If the window didn&apos;t open (popups
blocked), use the button.
</Callout>
<div className="hstack" style={{ gap: 10, flexWrap: "wrap" }}>
<Button
variant="secondary"
disabled={!url}
onClick={() => {
if (url) popupRef.current = openLoginPopup(url);
}}
>
Open Claude sign-in window
</Button>
{url && (
<a className="btn btn-ghost" href={url} target="_blank" rel="noopener noreferrer">
Open in a new tab
</a>
)}
<Button variant="ghost" disabled={busy} onClick={start}>
Restart
</Button>
</div>
<div className="hstack" style={{ gap: 10, alignItems: "flex-end", flexWrap: "wrap" }}>
<div style={{ flex: "1 1 320px" }}>
<Input
label="Authorization code"
value={code}
onChange={setCode}
placeholder="Paste the code from claude.com"
disabled={status === "submitting"}
/>
</div>
<Button
variant="primary"
disabled={status === "submitting" || !code.trim()}
onClick={submit}
>
{status === "submitting" ? "Submitting…" : "Finish login"}
</Button>
</div>
</>
)}
</div>
</>
);
}