import { useStore } from '@nanostores/react' import type { ReactNode } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react' import { runInTerminal } from '@/app/right-sidebar/store' import { FEATURED_ID, FeaturedProviderRow, KeyProviderRow, ProviderRow, providerTitle, sortProviders } from '@/components/desktop-onboarding-overlay' import { Button } from '@/components/ui/button' import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes' import { useI18n } from '@/i18n' import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding' import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' import { isKeyVar, ProviderKeyRows } from './credential-key-ui' import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' import { providerGroup, providerMeta, providerPriority } from './helpers' import { LoadingState, SettingsContent } from './primitives' // The embedded terminal (and thus the "run disconnect command" path) only // exists in the Electron desktop shell, not the web dashboard. const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal) // Parallel group headers ("Connected", "Other providers") so the expanded list // reads as its own section instead of bleeding into the connected group. function GroupLabel({ children }: { children: ReactNode }) { return (

{children}

) } // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. export const PROVIDER_VIEWS = ['accounts', 'keys'] as const export type ProviderView = (typeof PROVIDER_VIEWS)[number] // Group the env catalog by provider — one ListRow per vendor plus optional // advanced overrides (base URL, region, etc.). Groups without a key field and // the "Other" bucket are skipped. function buildProviderKeyGroups(vars: Record): ProviderKeyGroup[] { const buckets = new Map() for (const [key, info] of Object.entries(vars)) { if (info.category !== 'provider') { continue } const name = providerGroup(key) if (name === 'Other') { continue } buckets.set(name, [...(buckets.get(name) ?? []), [key, info]]) } const groups: ProviderKeyGroup[] = [] for (const [name, entries] of buckets) { const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i)) if (!primary) { continue } const meta = providerMeta(name) groups.push({ // Advanced = the provider's non-key knobs (base URL, region, deployment). // Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY) // so we never render a second "Paste key" input — unless one is already // set, in which case keep it visible so it stays clearable. advanced: entries .filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set)) .sort(([a], [b]) => a.localeCompare(b)), description: meta?.description ?? primary[1].description, docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined, hasAnySet: entries.some(([, i]) => i.is_set), name, primary, priority: providerPriority(name) }) } return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) } // Deliberately a near-1:1 replica of the first-run onboarding picker // (`Picker` in desktop-onboarding-overlay): same recommended card, same // provider rows, same "Other providers" disclosure, same OpenRouter quick-key // row, and the same bottom-right "I have an API key" affordance. The leaf cards // are the exact shared components, so the two surfaces stay visually identical. // Selecting a provider hands off to the shared onboarding overlay, which runs // that provider's real sign-in flow; the key affordances open the API-key // catalog below. function OAuthPicker({ disconnecting, onDisconnect, onTerminalDisconnect, onWantApiKey, providers }: { disconnecting: null | string onDisconnect: (provider: OAuthProvider) => void onTerminalDisconnect: (provider: OAuthProvider) => void onWantApiKey: () => void providers: OAuthProvider[] }) { const { t } = useI18n() const p = t.settings.providers const [showAll, setShowAll] = useState(false) const ordered = useMemo(() => sortProviders(providers), [providers]) if (ordered.length === 0) { return null } const select = (p: OAuthProvider) => startManualProviderOAuth(p.id) const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered // Keep connected accounts grouped and always visible; only the unconnected // providers hide behind the disclosure, so the page leads with what's set up. const connected = rest.filter(p => p.status?.logged_in) const others = rest.filter(p => !p.status?.logged_in) const collapsible = others.length > 0 const showOthers = !collapsible || showAll return (

{p.intro}

{featured && } {connected.length > 0 && ( <> {p.connected} {connected.map(p => ( ))} )} {showOthers && ( <> {connected.length > 0 && {p.otherProviders}} {others.map(p => ( ))} )} {collapsible && ( )}
) } function ConnectedProviderRow({ disconnecting, onDisconnect, onSelect, onTerminalDisconnect, provider }: { disconnecting: boolean onDisconnect: (provider: OAuthProvider) => void onSelect: (provider: OAuthProvider) => void onTerminalDisconnect: (provider: OAuthProvider) => void provider: OAuthProvider }) { const { t } = useI18n() const copy = t.settings.providers const title = providerTitle(provider) const Trail = provider.flow === 'external' ? Terminal : ChevronRight // Hermes can clear this provider's creds via the API. const canDisconnect = provider.disconnectable ?? provider.flow !== 'external' // External (CLI-managed) provider Hermes can't clear via the API, but ships a // command we can run in the embedded terminal (Electron shell only). const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal() // Only fall back to a static "remove it elsewhere" hint when we offer no button. const showHint = !canDisconnect && !terminalDisconnect return (
{canDisconnect && ( )} {terminalDisconnect && ( )}
) } function NoProviderKeys() { const { t } = useI18n() return (
{t.settings.providers.noProviderKeys}
) } export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) { const { t } = useI18n() const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) const [openProvider, setOpenProvider] = useState(null) const [disconnecting, setDisconnecting] = useState(null) // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we // re-read connection state when the user finishes (or dismisses) a sign-in // they launched from this page — otherwise the cards keep their stale status. const onboardingActive = useStore($desktopOnboarding).manual const refreshOAuthProviders = useCallback(async () => { // OAuth providers are best-effort — a failure here just hides the panel. const { providers } = await listOAuthProviders() setOauthProviders(providers) }, []) useEffect(() => { let cancelled = false void (async () => { if (onboardingActive) { return } try { const { providers } = await listOAuthProviders() if (!cancelled) { setOauthProviders(providers) } } catch { // Ignore — the OAuth panel just won't render. } })() return () => void (cancelled = true) }, [onboardingActive]) // External (CLI-managed) providers can't be cleared via the API by design — // Hermes never deletes creds another tool owns behind a silent API call. // Instead we run the documented removal command in the embedded terminal so // the user sees exactly what executes, then return them to chat to watch it. function handleTerminalDisconnect(provider: OAuthProvider) { const command = provider.disconnect_command if (!command) { return } const name = providerTitle(provider) if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) { return } // Leave the settings overlay so the terminal pane (chat-only) is visible. onClose() runInTerminal(command) notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) }) } async function handleDisconnect(provider: OAuthProvider) { const name = providerTitle(provider) if (!window.confirm(t.settings.providers.removeConfirm(name))) { return } setDisconnecting(provider.id) try { await disconnectOAuthProvider(provider.id) notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) }) await refreshOAuthProviders().catch(() => undefined) } catch (err) { notifyError(err, t.settings.providers.failedRemove(name)) } finally { setDisconnecting(null) } } if (!vars) { return } const hasOauth = oauthProviders.length > 0 // The sidebar subnav owns the Accounts/API-keys split now; with no OAuth // providers there's nothing for the "Accounts" view to show, so fall to keys. const showApiKeys = view === 'keys' || !hasOauth const keyGroups = buildProviderKeyGroups(vars) if (showApiKeys) { return ( {keyGroups.length > 0 ? (
{keyGroups.map(group => ( setOpenProvider(group.name)} onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))} rowProps={rowProps} /> ))}
) : ( )}
) } return ( void handleDisconnect(provider)} onTerminalDisconnect={handleTerminalDisconnect} onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} /> ) } interface ProviderKeyGroup { advanced: [string, EnvVarInfo][] description?: string docsUrl?: string hasAnySet: boolean name: string primary: [string, EnvVarInfo] priority: number } interface ProvidersSettingsProps { onClose: () => void onViewChange: (view: ProviderView) => void view: ProviderView }