feat: dashboard OAuth provider management
Add OAuth provider management to the Hermes dashboard with full
lifecycle support for Anthropic (PKCE), Nous and OpenAI Codex
(device-code) flows.
## Backend (hermes_cli/web_server.py)
- 6 new API endpoints:
GET /api/providers/oauth — list providers with connection status
POST /api/providers/oauth/{id}/start — initiate PKCE or device-code
POST /api/providers/oauth/{id}/submit — exchange PKCE auth code
GET /api/providers/oauth/{id}/poll/{session} — poll device-code
DELETE /api/providers/oauth/{id} — disconnect provider
DELETE /api/providers/oauth/sessions/{id} — cancel pending session
- OAuth constants imported from anthropic_adapter (no duplication)
- Blocking I/O wrapped in run_in_executor for async safety
- In-memory session store with 15-minute TTL and automatic GC
- Auth token required on all mutating endpoints
## Frontend
- OAuthLoginModal — PKCE (paste auth code) and device-code (poll) flows
- OAuthProvidersCard — status, token preview, connect/disconnect actions
- Toast fix: createPortal to document.body for correct z-index
- App.tsx: skip animation key bump on initial mount (prevent double-mount)
- Integrated into the Env/Keys page
This commit is contained in:
365
web/src/components/OAuthLoginModal.tsx
Normal file
365
web/src/components/OAuthLoginModal.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user