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

@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
MessageSquare,
Search,
@ -287,6 +288,9 @@ function SessionRow({
export default function SessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const PAGE_SIZE = 20;
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
@ -294,17 +298,21 @@ export default function SessionsPage() {
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const loadSessions = useCallback(() => {
const loadSessions = useCallback((p: number) => {
setLoading(true);
api
.getSessions()
.then(setSessions)
.getSessions(PAGE_SIZE, p * PAGE_SIZE)
.then((resp) => {
setSessions(resp.sessions);
setTotal(resp.total);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
loadSessions(page);
}, [loadSessions, page]);
// Debounced FTS search
useEffect(() => {
@ -334,6 +342,7 @@ export default function SessionsPage() {
try {
await api.deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
} catch {
// ignore
@ -370,7 +379,7 @@ export default function SessionsPage() {
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">Sessions</h1>
<Badge variant="secondary" className="text-xs">
{sessions.length}
{total}
</Badge>
</div>
<div className="relative w-64">
@ -408,21 +417,57 @@ export default function SessionsPage() {
)}
</div>
) : (
<div className="flex flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => handleDelete(s.id)}
/>
))}
</div>
<>
<div className="flex flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => handleDelete(s.id)}
/>
))}
</div>
{/* Pagination — hidden during search */}
{!searchResults && total > PAGE_SIZE && (
<div className="flex items-center justify-between pt-2">
<span className="text-xs text-muted-foreground">
{page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {Math.ceil(total / PAGE_SIZE)}
</span>
<Button
variant="outline"
size="sm"
className="h-7 w-7 p-0"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => setPage((p) => p + 1)}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</div>
);