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:
kshitijk4poor 2026-04-13 19:08:45 +05:30 committed by Teknium
parent 2773b18b56
commit 247929b0dd
11 changed files with 1789 additions and 96 deletions

View 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>
);
}

View file

@ -0,0 +1,290 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { ShieldCheck, ShieldOff, Copy, ExternalLink, RefreshCw, LogOut, Terminal, LogIn } from "lucide-react";
import { api, type OAuthProvider } from "@/lib/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
/**
* OAuthProvidersCard surfaces every OAuth-capable LLM provider with its
* current connection status, a truncated token preview when connected, and
* action buttons (Copy CLI command for setup, Disconnect for cleanup).
*
* Phase 1 scope: read-only status + disconnect + copy-to-clipboard CLI
* command. Phase 2 will add in-browser PKCE / device-code flows so users
* never need to drop to a terminal.
*/
interface Props {
onError?: (msg: string) => void;
onSuccess?: (msg: string) => void;
}
const FLOW_LABELS: Record<OAuthProvider["flow"], string> = {
pkce: "Browser login (PKCE)",
device_code: "Device code",
external: "External CLI",
};
function formatExpiresAt(expiresAt: string | null | undefined): string | null {
if (!expiresAt) return null;
try {
const dt = new Date(expiresAt);
if (Number.isNaN(dt.getTime())) return null;
const now = Date.now();
const diff = dt.getTime() - now;
if (diff < 0) return "expired";
const mins = Math.floor(diff / 60_000);
if (mins < 60) return `expires in ${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `expires in ${hours}h`;
const days = Math.floor(hours / 24);
return `expires in ${days}d`;
} catch {
return null;
}
}
export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
// Provider that the login modal is currently open for. null = modal closed.
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
// Use refs for callbacks to avoid re-creating refresh() when parent re-renders
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const refresh = useCallback(() => {
setLoading(true);
api
.getOAuthProviders()
.then((resp) => setProviders(resp.providers))
.catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const handleCopy = async (provider: OAuthProvider) => {
try {
await navigator.clipboard.writeText(provider.cli_command);
setCopiedId(provider.id);
onSuccess?.(`Copied: ${provider.cli_command}`);
setTimeout(() => setCopiedId((v) => (v === provider.id ? null : v)), 1500);
} catch {
onError?.("Clipboard write failed — copy the command manually");
}
};
const handleDisconnect = async (provider: OAuthProvider) => {
if (!confirm(`Disconnect ${provider.name}? You'll need to log in again to use this provider.`)) {
return;
}
setBusyId(provider.id);
try {
await api.disconnectOAuthProvider(provider.id);
onSuccess?.(`${provider.name} disconnected`);
refresh();
} catch (e) {
onError?.(`Disconnect failed: ${e}`);
} finally {
setBusyId(null);
}
};
const connectedCount = providers?.filter((p) => p.status.logged_in).length ?? 0;
const totalCount = providers?.length ?? 0;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Provider Logins (OAuth)</CardTitle>
</div>
<Button
variant="ghost"
size="sm"
onClick={refresh}
disabled={loading}
className="text-xs"
>
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<CardDescription>
{connectedCount} of {totalCount} OAuth providers connected. Login flows currently
run via the CLI; click <em>Copy command</em> and paste into a terminal to set up.
</CardDescription>
</CardHeader>
<CardContent>
{loading && providers === null && (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
{providers && providers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
No OAuth-capable providers detected.
</p>
)}
<div className="flex flex-col divide-y divide-border">
{providers?.map((p) => {
const expiresLabel = formatExpiresAt(p.status.expires_at);
const isBusy = busyId === p.id;
return (
<div
key={p.id}
className="flex items-center justify-between gap-4 py-3"
>
{/* Left: status icon + name + source */}
<div className="flex items-start gap-3 min-w-0 flex-1">
{p.status.logged_in ? (
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
) : (
<ShieldOff className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
)}
<div className="flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
{FLOW_LABELS[p.flow]}
</Badge>
{p.status.logged_in && (
<Badge variant="success" className="text-[11px]">
Connected
</Badge>
)}
{expiresLabel === "expired" && (
<Badge variant="destructive" className="text-[11px]">
Expired
</Badge>
)}
{expiresLabel && expiresLabel !== "expired" && (
<Badge variant="outline" className="text-[11px]">
{expiresLabel}
</Badge>
)}
</div>
{p.status.logged_in && p.status.token_preview && (
<code className="text-xs text-muted-foreground font-mono-ui truncate">
token{" "}
<span className="text-foreground">{p.status.token_preview}</span>
{p.status.source_label && (
<span className="text-muted-foreground/70">
{" "}· {p.status.source_label}
</span>
)}
</code>
)}
{!p.status.logged_in && (
<span className="text-xs text-muted-foreground/80">
Not connected. Run{" "}
<code className="text-foreground bg-secondary/40 px-1 rounded">
{p.cli_command}
</code>{" "}
in a terminal.
</span>
)}
{p.status.error && (
<span className="text-xs text-destructive">
{p.status.error}
</span>
)}
</div>
</div>
{/* Right: action buttons */}
<div className="flex items-center gap-1.5 shrink-0">
{p.docs_url && (
<a
href={p.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
title={`Open ${p.name} docs`}
>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</a>
)}
{!p.status.logged_in && p.flow !== "external" && (
<Button
variant="default"
size="sm"
onClick={() => setLoginFor(p)}
className="text-xs h-7"
title={`Start ${p.flow === "pkce" ? "browser" : "device code"} login`}
>
<LogIn className="h-3 w-3 mr-1" />
Login
</Button>
)}
{!p.status.logged_in && (
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(p)}
className="text-xs h-7"
title="Copy CLI command (for external / fallback)"
>
{copiedId === p.id ? (
<>Copied </>
) : (
<>
<Copy className="h-3 w-3 mr-1" />
CLI
</>
)}
</Button>
)}
{p.status.logged_in && p.flow !== "external" && (
<Button
variant="outline"
size="sm"
onClick={() => handleDisconnect(p)}
disabled={isBusy}
className="text-xs h-7"
>
{isBusy ? (
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
) : (
<LogOut className="h-3 w-3 mr-1" />
)}
Disconnect
</Button>
)}
{p.status.logged_in && p.flow === "external" && (
<span className="text-[11px] text-muted-foreground italic px-2">
<Terminal className="h-3 w-3 inline mr-0.5" />
Managed externally
</span>
)}
</div>
</div>
);
})}
</div>
</CardContent>
{loginFor && (
<OAuthLoginModal
provider={loginFor}
onClose={() => {
setLoginFor(null);
refresh(); // always refresh on close so token preview updates after login
}}
onSuccess={(msg) => onSuccess?.(msg)}
onError={(msg) => onError?.(msg)}
/>
)}
</Card>
);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
const [visible, setVisible] = useState(false);
@ -17,11 +18,13 @@ export function Toast({ toast }: { toast: { message: string; type: "success" | "
if (!current) return null;
return (
// Portal to document.body so the toast escapes any ancestor stacking context
// (e.g. <main> has `relative z-2`, which would trap z-50 below the header's z-40).
return createPortal(
<div
role="status"
aria-live="polite"
className={`fixed top-4 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
className={`fixed top-16 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
current.type === "success"
? "bg-success/15 text-success border-success/30"
: "bg-destructive/15 text-destructive border-destructive/30"
@ -31,6 +34,7 @@ export function Toast({ toast }: { toast: { message: string; type: "success" | "
}}
>
{current.message}
</div>
</div>,
document.body,
);
}