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

@ -22,7 +22,8 @@ async function getSessionToken(): Promise<string> {
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
getSessionMessages: (id: string) =>
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
deleteSession: (id: string) =>
@ -110,6 +111,62 @@ export const api = {
// Session search (FTS5)
searchSessions: (q: string) =>
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
// OAuth provider management
getOAuthProviders: () =>
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
disconnectOAuthProvider: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean; provider: string }>(
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
},
startOAuthLogin: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthStartResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: "{}",
},
);
},
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthSubmitResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ session_id: sessionId, code }),
},
);
},
pollOAuthSession: (providerId: string, sessionId: string) =>
fetchJSON<OAuthPollResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
),
cancelOAuthSession: async (sessionId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean }>(
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
},
);
},
};
export interface PlatformStatus {
@ -152,6 +209,13 @@ export interface SessionInfo {
preview: string | null;
}
export interface PaginatedSessions {
sessions: SessionInfo[];
total: number;
limit: number;
offset: number;
}
export interface EnvVarInfo {
is_set: boolean;
redacted_value: string | null;
@ -260,3 +324,61 @@ export interface SessionSearchResult {
export interface SessionSearchResponse {
results: SessionSearchResult[];
}
// ── OAuth provider types ────────────────────────────────────────────────
export interface OAuthProviderStatus {
logged_in: boolean;
source?: string | null;
source_label?: string | null;
token_preview?: string | null;
expires_at?: string | null;
has_refresh_token?: boolean;
last_refresh?: string | null;
error?: string;
}
export interface OAuthProvider {
id: string;
name: string;
/** "pkce" (browser redirect + paste code), "device_code" (show code + URL),
* or "external" (delegated to a separate CLI like Claude Code or Qwen). */
flow: "pkce" | "device_code" | "external";
cli_command: string;
docs_url: string;
status: OAuthProviderStatus;
}
export interface OAuthProvidersResponse {
providers: OAuthProvider[];
}
/** Discriminated union — the shape of /start depends on the flow. */
export type OAuthStartResponse =
| {
session_id: string;
flow: "pkce";
auth_url: string;
expires_in: number;
}
| {
session_id: string;
flow: "device_code";
user_code: string;
verification_url: string;
expires_in: number;
poll_interval: number;
};
export interface OAuthSubmitResponse {
ok: boolean;
status: "approved" | "error";
message?: string;
}
export interface OAuthPollResponse {
session_id: string;
status: "pending" | "approved" | "denied" | "expired" | "error";
error_message?: string | null;
expires_at?: number | null;
}