Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
924 changes: 917 additions & 7 deletions hermes_cli/web_server.py

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
import StatusPage from "@/pages/StatusPage";
import ConfigPage from "@/pages/ConfigPage";
Expand Down Expand Up @@ -36,8 +36,15 @@ const PAGE_COMPONENTS: Record<PageId, React.FC> = {
export default function App() {
const [page, setPage] = useState<PageId>("status");
const [animKey, setAnimKey] = useState(0);
const initialRef = useRef(true);

useEffect(() => {
// Skip the animation key bump on initial mount to avoid re-mounting
// the default page component (which causes duplicate API requests).
if (initialRef.current) {
initialRef.current = false;
return;
}
setAnimKey((k) => k + 1);
}, [page]);

Expand Down
365 changes: 365 additions & 0 deletions web/src/components/OAuthLoginModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,365 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

/**
* OAuthLoginModal — drives the in-browser OAuth flow for a single provider.
*
* Two variants share the same modal shell:
*
* - PKCE (Anthropic): user opens the auth URL in a new tab, authorizes,
* pastes the resulting code back. We POST it to /submit which exchanges
* the (code + verifier) pair for tokens server-side.
*
* - Device code (Nous, OpenAI Codex): we display the verification URL
* and short user code; the backend polls the provider's token endpoint
* in a background thread; we poll /poll/{session_id} every 2s for status.
*
* Edge cases handled:
* - Popup blocker (we use plain anchor href + open in new tab; no popup
* window.open which is more likely to be blocked).
* - Modal dismissal mid-flight cancels the server-side session via DELETE.
* - Code expiry surfaces as a clear error state with retry button.
* - Polling continues to work if the user backgrounds the tab (setInterval
* keeps firing in modern browsers; we guard against polls firing after
* component unmount via an isMounted ref).
*/

interface Props {
provider: OAuthProvider;
onClose: () => void;
onSuccess: (msg: string) => void;
onError: (msg: string) => void;
}

type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error";

export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) {
const [phase, setPhase] = useState<Phase>("starting");
const [start, setStart] = useState<OAuthStartResponse | null>(null);
const [pkceCode, setPkceCode] = useState("");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const [codeCopied, setCodeCopied] = useState(false);
const isMounted = useRef(true);
const pollTimer = useRef<number | null>(null);

// Initiate flow on mount
useEffect(() => {
isMounted.current = true;
api
.startOAuthLogin(provider.id)
.then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
if (resp.flow === "pkce") {
// Auto-open the auth URL in a new tab
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
} else {
// Device-code: open the verification URL automatically
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
}
})
.catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Failed to start login: ${e}`);
});
return () => {
isMounted.current = false;
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
};
// We only want to start the flow once on mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Tick the countdown
useEffect(() => {
if (secondsLeft === null) return;
if (phase === "approved" || phase === "error") return;
const tick = window.setInterval(() => {
if (!isMounted.current) return;
setSecondsLeft((s) => {
if (s !== null && s <= 1) {
// Session expired — transition to error state
setPhase("error");
setErrorMsg("Session expired. Click Retry to start a new login.");
return 0;
}
return s !== null && s > 0 ? s - 1 : 0;
});
}, 1000);
return () => window.clearInterval(tick);
}, [secondsLeft, phase]);

// Device-code: poll backend every 2s
useEffect(() => {
if (!start || start.flow !== "device_code" || phase !== "polling") return;
const sid = start.session_id;
pollTimer.current = window.setInterval(async () => {
try {
const resp = await api.pollOAuthSession(provider.id, sid);
if (!isMounted.current) return;
if (resp.status === "approved") {
setPhase("approved");
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500);
} else if (resp.status !== "pending") {
setPhase("error");
setErrorMsg(resp.error_message || `Login ${resp.status}`);
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
}
} catch (e) {
// 404 = session expired/cleaned up; treat as error
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Polling failed: ${e}`);
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
}
}, 2000);
return () => {
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
};
}, [start, phase, provider.id, provider.name, onSuccess, onClose]);

const handleSubmitPkceCode = async () => {
if (!start || start.flow !== "pkce") return;
if (!pkceCode.trim()) return;
setPhase("submitting");
setErrorMsg(null);
try {
const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim());
if (!isMounted.current) return;
if (resp.ok && resp.status === "approved") {
setPhase("approved");
onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500);
} else {
setPhase("error");
setErrorMsg(resp.message || "Token exchange failed");
}
} catch (e) {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Submit failed: ${e}`);
}
};

const handleClose = async () => {
// Cancel server session if still in flight
if (start && phase !== "approved" && phase !== "error") {
try {
await api.cancelOAuthSession(start.session_id);
} catch {
// ignore — server-side TTL will clean it up anyway
}
}
onClose();
};

const handleCopyUserCode = async (code: string) => {
try {
await navigator.clipboard.writeText(code);
setCodeCopied(true);
window.setTimeout(() => isMounted.current && setCodeCopied(false), 1500);
} catch {
onError("Clipboard write failed");
}
};

// Backdrop click closes
const handleBackdrop = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) handleClose();
};

const fmtTime = (s: number | null) => {
if (s === null) return "";
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
};

return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={handleBackdrop}
role="dialog"
aria-modal="true"
aria-labelledby="oauth-modal-title"
>
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl">
<button
type="button"
onClick={handleClose}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<div className="p-6 flex flex-col gap-4">
<div>
<h2 id="oauth-modal-title" className="font-display text-base tracking-wider uppercase">
Connect {provider.name}
</h2>
{secondsLeft !== null && phase !== "approved" && phase !== "error" && (
<p className="text-xs text-muted-foreground mt-1">
Session expires in {fmtTime(secondsLeft)}
</p>
)}
</div>

{/* ── starting ───────────────────────────────────── */}
{phase === "starting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Initiating login flow…
</div>
)}

{/* ── PKCE: paste code ───────────────────────────── */}
{start?.flow === "pkce" && phase === "awaiting_user" && (
<>
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
<li>
A new tab opened to <code className="text-foreground">claude.ai</code>. Sign in
and click <strong className="text-foreground">Authorize</strong>.
</li>
<li>Copy the <strong className="text-foreground">authorization code</strong> shown after authorizing.</li>
<li>Paste it below and submit.</li>
</ol>
<div className="flex flex-col gap-2">
<Input
value={pkceCode}
onChange={(e) => setPkceCode(e.target.value)}
placeholder="Paste authorization code (with #state suffix is fine)"
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
autoFocus
/>
<div className="flex items-center gap-2 justify-between">
<a
href={(start as Extract<OAuthStartResponse, { flow: "pkce" }>).auth_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
Re-open auth page
</a>
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
Submit code
</Button>
</div>
</div>
</>
)}

{/* ── PKCE: submitting exchange ──────────────────── */}
{phase === "submitting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Exchanging code for tokens…
</div>
)}

{/* ── Device code: show code + URL, polling ──────── */}
{start?.flow === "device_code" && phase === "polling" && (
<>
<p className="text-sm text-muted-foreground">
A new tab opened. Enter this code if prompted:
</p>
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
{(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code}
</code>
<Button
variant="outline"
size="sm"
onClick={() =>
handleCopyUserCode(
(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code,
)
}
className="text-xs"
>
{codeCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
<a
href={(start as Extract<OAuthStartResponse, { flow: "device_code" }>).verification_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
Re-open verification page
</a>
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
<Loader2 className="h-3 w-3 animate-spin" />
Waiting for you to authorize in the browser…
</div>
</>
)}

{/* ── approved ───────────────────────────────────── */}
{phase === "approved" && (
<div className="flex items-center gap-3 py-6 text-sm text-success">
<Check className="h-5 w-5" />
Connected! Closing…
</div>
)}

{/* ── error ──────────────────────────────────────── */}
{phase === "error" && (
<>
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{errorMsg || "Login failed."}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleClose}>
Close
</Button>
<Button
size="sm"
onClick={() => {
// Cancel the old session before starting a new one
if (start?.session_id) {
api.cancelOAuthSession(start.session_id).catch(() => {});
}
setErrorMsg(null);
setStart(null);
setPkceCode("");
setPhase("starting");
// Re-trigger the start effect by remounting (caller should re-key us)
// Simpler: just kick off a new start manually
api.startOAuthLogin(provider.id).then((resp) => {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
if (resp.flow === "pkce") {
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
} else {
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
}
}).catch((e) => {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Retry failed: ${e}`);
});
}}
>
Retry
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
Loading
Loading