|
|
|
@@ -1,14 +1,21 @@
|
|
|
|
|
import { useStore } from '@nanostores/react'
|
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
|
import { getEnvVars, setEnvVar } from '@/hermes'
|
|
|
|
|
import { AlertCircle, Check, ExternalLink, KeyRound, Loader2 } from '@/lib/icons'
|
|
|
|
|
import {
|
|
|
|
|
cancelOAuthSession,
|
|
|
|
|
listOAuthProviders,
|
|
|
|
|
pollOAuthSession,
|
|
|
|
|
setEnvVar,
|
|
|
|
|
startOAuthLogin,
|
|
|
|
|
submitOAuthCode
|
|
|
|
|
} from '@/hermes'
|
|
|
|
|
import { Check, ChevronRight, ExternalLink, KeyRound, Loader2, Sparkles } from '@/lib/icons'
|
|
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
import { notify, notifyError } from '@/store/notifications'
|
|
|
|
|
import { $desktopOnboarding, completeDesktopOnboarding } from '@/store/onboarding'
|
|
|
|
|
import type { EnvVarInfo } from '@/types/hermes'
|
|
|
|
|
import type { OAuthProvider, OAuthStartResponse } from '@/types/hermes'
|
|
|
|
|
|
|
|
|
|
interface DesktopOnboardingOverlayProps {
|
|
|
|
|
enabled: boolean
|
|
|
|
@@ -22,244 +29,503 @@ interface SetupStatus {
|
|
|
|
|
|
|
|
|
|
interface RuntimeCheck {
|
|
|
|
|
error?: string
|
|
|
|
|
model?: string
|
|
|
|
|
ok?: boolean
|
|
|
|
|
provider?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProviderOption {
|
|
|
|
|
key: string
|
|
|
|
|
label: string
|
|
|
|
|
helper: string
|
|
|
|
|
interface ApiKeyOption {
|
|
|
|
|
description: string
|
|
|
|
|
docsUrl: string
|
|
|
|
|
envKey: string
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
placeholder?: string
|
|
|
|
|
short?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PREFERRED_PROVIDER_KEYS: ProviderOption[] = [
|
|
|
|
|
const API_KEY_OPTIONS: ApiKeyOption[] = [
|
|
|
|
|
{
|
|
|
|
|
key: 'OPENROUTER_API_KEY',
|
|
|
|
|
label: 'OpenRouter',
|
|
|
|
|
helper: 'Works with many hosted models and is a good default for new installs.'
|
|
|
|
|
id: 'openrouter',
|
|
|
|
|
name: 'OpenRouter',
|
|
|
|
|
short: 'one key, many models',
|
|
|
|
|
envKey: 'OPENROUTER_API_KEY',
|
|
|
|
|
description: 'Hosts hundreds of models behind a single key. Good default for new installs.',
|
|
|
|
|
docsUrl: 'https://openrouter.ai/keys'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'ANTHROPIC_API_KEY',
|
|
|
|
|
label: 'Anthropic',
|
|
|
|
|
helper: 'Use Claude models directly.'
|
|
|
|
|
id: 'openai',
|
|
|
|
|
name: 'OpenAI',
|
|
|
|
|
short: 'GPT-class models',
|
|
|
|
|
envKey: 'OPENAI_API_KEY',
|
|
|
|
|
description: 'Direct access to OpenAI models.',
|
|
|
|
|
docsUrl: 'https://platform.openai.com/api-keys'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'OPENAI_API_KEY',
|
|
|
|
|
label: 'OpenAI',
|
|
|
|
|
helper: 'Use OpenAI models directly.'
|
|
|
|
|
id: 'gemini',
|
|
|
|
|
name: 'Google Gemini',
|
|
|
|
|
short: 'Gemini models',
|
|
|
|
|
envKey: 'GEMINI_API_KEY',
|
|
|
|
|
description: 'Direct access to Google Gemini models.',
|
|
|
|
|
docsUrl: 'https://aistudio.google.com/app/apikey'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'GEMINI_API_KEY',
|
|
|
|
|
label: 'Gemini',
|
|
|
|
|
helper: 'Use Google Gemini models.'
|
|
|
|
|
id: 'xai',
|
|
|
|
|
name: 'xAI Grok',
|
|
|
|
|
short: 'Grok models',
|
|
|
|
|
envKey: 'XAI_API_KEY',
|
|
|
|
|
description: 'Direct access to xAI Grok models.',
|
|
|
|
|
docsUrl: 'https://console.x.ai/'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'XAI_API_KEY',
|
|
|
|
|
label: 'xAI',
|
|
|
|
|
helper: 'Use Grok models.'
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'OPENAI_BASE_URL',
|
|
|
|
|
label: 'Local / OpenAI-compatible',
|
|
|
|
|
helper: 'Use a local or self-hosted OpenAI-compatible endpoint. API key may not be required.'
|
|
|
|
|
id: 'local',
|
|
|
|
|
name: 'Local / custom endpoint',
|
|
|
|
|
short: 'self-hosted',
|
|
|
|
|
envKey: 'OPENAI_BASE_URL',
|
|
|
|
|
description: 'Point Hermes at a local or self-hosted OpenAI-compatible endpoint (vLLM, llama.cpp, Ollama, etc).',
|
|
|
|
|
docsUrl: 'https://github.com/NousResearch/hermes-agent#bring-your-own-endpoint',
|
|
|
|
|
placeholder: 'http://127.0.0.1:8000/v1'
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
function optionLabel(option: ProviderOption, info?: EnvVarInfo) {
|
|
|
|
|
return info?.description ? `${option.label} (${option.key})` : option.label
|
|
|
|
|
interface FlowState {
|
|
|
|
|
copyState?: 'copied' | 'idle'
|
|
|
|
|
errorMessage?: string
|
|
|
|
|
expiresAt?: number
|
|
|
|
|
provider?: OAuthProvider
|
|
|
|
|
start?: OAuthStartResponse
|
|
|
|
|
status: 'awaiting_user' | 'error' | 'idle' | 'polling' | 'starting' | 'submitting' | 'success'
|
|
|
|
|
submitCode?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const POLL_INTERVAL_MS = 2000
|
|
|
|
|
|
|
|
|
|
export function DesktopOnboardingOverlay({
|
|
|
|
|
enabled,
|
|
|
|
|
onCompleted,
|
|
|
|
|
requestGateway
|
|
|
|
|
}: DesktopOnboardingOverlayProps) {
|
|
|
|
|
const onboarding = useStore($desktopOnboarding)
|
|
|
|
|
const [checking, setChecking] = useState(false)
|
|
|
|
|
const [envVars, setEnvVars] = useState<Record<string, EnvVarInfo> | null>(null)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
const [providerConfigured, setProviderConfigured] = useState(true)
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
const [selectedKey, setSelectedKey] = useState(PREFERRED_PROVIDER_KEYS[0].key)
|
|
|
|
|
const [value, setValue] = useState('')
|
|
|
|
|
const shouldCheck = enabled || onboarding.requested
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!shouldCheck) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
|
|
|
|
async function checkSetup() {
|
|
|
|
|
setChecking(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[] | null>(null)
|
|
|
|
|
const [mode, setMode] = useState<'apikey' | 'oauth'>('oauth')
|
|
|
|
|
const [flow, setFlow] = useState<FlowState>({ status: 'idle' })
|
|
|
|
|
const [apiKeyOption, setApiKeyOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
|
|
|
|
|
const [apiKeyValue, setApiKeyValue] = useState('')
|
|
|
|
|
const [apiKeySaving, setApiKeySaving] = useState(false)
|
|
|
|
|
const [apiKeyError, setApiKeyError] = useState<string | null>(null)
|
|
|
|
|
const cancelledRef = useRef(false)
|
|
|
|
|
const pollTimerRef = useRef<number | null>(null)
|
|
|
|
|
const shouldShow = enabled || onboarding.requested
|
|
|
|
|
|
|
|
|
|
const refreshSetupCheck = useMemo(
|
|
|
|
|
() => async () => {
|
|
|
|
|
try {
|
|
|
|
|
const [status, runtime, vars] = await Promise.all([
|
|
|
|
|
const [status, runtime] = await Promise.all([
|
|
|
|
|
requestGateway<SetupStatus>('setup.status').catch(() => ({}) as SetupStatus),
|
|
|
|
|
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => ({ ok: false }) as RuntimeCheck),
|
|
|
|
|
getEnvVars()
|
|
|
|
|
requestGateway<RuntimeCheck>('setup.runtime_check').catch(() => ({ ok: false }) as RuntimeCheck)
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The strict runtime check is the source of truth: it runs the same
|
|
|
|
|
// resolver the agent uses, so it can't be fooled by fallbacks like
|
|
|
|
|
// gh-CLI Copilot tokens when the user's configured model isn't a
|
|
|
|
|
// Copilot model. setup.status is kept as a coarse sanity check
|
|
|
|
|
// for backends that don't expose setup.runtime_check yet.
|
|
|
|
|
const runtimeOk = runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
|
|
|
|
|
|
|
|
|
|
setProviderConfigured(runtimeOk)
|
|
|
|
|
setEnvVars(vars)
|
|
|
|
|
|
|
|
|
|
if (runtimeOk) {
|
|
|
|
|
completeDesktopOnboarding()
|
|
|
|
|
} else if (runtime.error) {
|
|
|
|
|
setError(runtime.error)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const firstAvailable = PREFERRED_PROVIDER_KEYS.find(option => vars[option.key])
|
|
|
|
|
|
|
|
|
|
if (firstAvailable) {
|
|
|
|
|
setSelectedKey(current => (vars[current] ? current : firstAvailable.key))
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setProviderConfigured(false)
|
|
|
|
|
setError(err instanceof Error ? err.message : String(err))
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setChecking(false)
|
|
|
|
|
}
|
|
|
|
|
return runtime.ok !== undefined ? Boolean(runtime.ok) : Boolean(status.provider_configured)
|
|
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void checkSetup()
|
|
|
|
|
|
|
|
|
|
return () => void (cancelled = true)
|
|
|
|
|
}, [requestGateway, shouldCheck])
|
|
|
|
|
|
|
|
|
|
const providerOptions = useMemo(
|
|
|
|
|
() => PREFERRED_PROVIDER_KEYS.filter(option => !envVars || envVars[option.key]),
|
|
|
|
|
[envVars]
|
|
|
|
|
},
|
|
|
|
|
[requestGateway]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const selectedInfo = envVars?.[selectedKey]
|
|
|
|
|
const selectedOption = providerOptions.find(option => option.key === selectedKey) ?? PREFERRED_PROVIDER_KEYS[0]
|
|
|
|
|
const canSave = selectedKey === 'OPENAI_BASE_URL' ? value.trim().length > 0 : value.trim().length > 8
|
|
|
|
|
|
|
|
|
|
async function handleSave() {
|
|
|
|
|
if (!canSave || saving) {
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!shouldShow) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setSaving(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
cancelledRef.current = false
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await setEnvVar(selectedKey, value.trim())
|
|
|
|
|
await requestGateway('reload.env').catch(() => undefined)
|
|
|
|
|
const status = await requestGateway<SetupStatus>('setup.status')
|
|
|
|
|
void (async () => {
|
|
|
|
|
const ok = await refreshSetupCheck()
|
|
|
|
|
|
|
|
|
|
if (!status.provider_configured) {
|
|
|
|
|
setError('Credential was saved, but Hermes still does not see a configured provider.')
|
|
|
|
|
if (cancelledRef.current) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setProviderConfigured(ok)
|
|
|
|
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
completeDesktopOnboarding()
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
notify({ kind: 'success', title: 'Hermes is ready', message: `${selectedKey} saved.` })
|
|
|
|
|
setProviderConfigured(true)
|
|
|
|
|
setValue('')
|
|
|
|
|
completeDesktopOnboarding()
|
|
|
|
|
onCompleted?.()
|
|
|
|
|
} catch (err) {
|
|
|
|
|
notifyError(err, `Failed to save ${selectedKey}`)
|
|
|
|
|
setError(err instanceof Error ? err.message : String(err))
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false)
|
|
|
|
|
try {
|
|
|
|
|
const providers = await listOAuthProviders()
|
|
|
|
|
|
|
|
|
|
if (cancelledRef.current) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setOauthProviders(providers.providers)
|
|
|
|
|
setMode(providers.providers.length > 0 ? 'oauth' : 'apikey')
|
|
|
|
|
} catch {
|
|
|
|
|
if (!cancelledRef.current) {
|
|
|
|
|
setOauthProviders([])
|
|
|
|
|
setMode('apikey')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelledRef.current = true
|
|
|
|
|
}
|
|
|
|
|
}, [refreshSetupCheck, shouldShow])
|
|
|
|
|
|
|
|
|
|
useEffect(
|
|
|
|
|
() => () => {
|
|
|
|
|
if (pollTimerRef.current !== null) {
|
|
|
|
|
window.clearInterval(pollTimerRef.current)
|
|
|
|
|
pollTimerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (flow.start?.session_id && flow.status !== 'success' && flow.status !== 'idle') {
|
|
|
|
|
cancelOAuthSession(flow.start.session_id).catch(() => undefined)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[flow.start?.session_id, flow.status]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
function clearPollTimer() {
|
|
|
|
|
if (pollTimerRef.current !== null) {
|
|
|
|
|
window.clearInterval(pollTimerRef.current)
|
|
|
|
|
pollTimerRef.current = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!shouldCheck || providerConfigured) {
|
|
|
|
|
async function finalizeSuccess(providerName: string) {
|
|
|
|
|
clearPollTimer()
|
|
|
|
|
notify({ kind: 'success', title: 'Hermes is ready', message: `${providerName} connected.` })
|
|
|
|
|
setFlow({ status: 'success' })
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await requestGateway('reload.env').catch(() => undefined)
|
|
|
|
|
} catch {
|
|
|
|
|
// best effort env reload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ok = await refreshSetupCheck()
|
|
|
|
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
setProviderConfigured(true)
|
|
|
|
|
completeDesktopOnboarding()
|
|
|
|
|
onCompleted?.()
|
|
|
|
|
} else {
|
|
|
|
|
setFlow({
|
|
|
|
|
status: 'error',
|
|
|
|
|
errorMessage: 'Connected, but Hermes still cannot resolve a usable provider. Try another provider.'
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startProviderFlow(provider: OAuthProvider) {
|
|
|
|
|
if (provider.flow === 'external') {
|
|
|
|
|
notify({
|
|
|
|
|
kind: 'info',
|
|
|
|
|
title: `${provider.name} uses an external CLI`,
|
|
|
|
|
message: `Run \`${provider.cli_command}\` in a terminal, then come back to retry.`
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearPollTimer()
|
|
|
|
|
setFlow({ status: 'starting', provider })
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const start = await startOAuthLogin(provider.id)
|
|
|
|
|
const expiresAt = Date.now() + start.expires_in * 1000
|
|
|
|
|
|
|
|
|
|
if (start.flow === 'pkce') {
|
|
|
|
|
await window.hermesDesktop?.openExternal(start.auth_url).catch(() => undefined)
|
|
|
|
|
setFlow({ status: 'awaiting_user', provider, start, expiresAt })
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await window.hermesDesktop?.openExternal(start.verification_url).catch(() => undefined)
|
|
|
|
|
setFlow({ status: 'polling', provider, start, expiresAt })
|
|
|
|
|
pollTimerRef.current = window.setInterval(() => void pollOAuth(provider, start), POLL_INTERVAL_MS)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
|
|
|
setFlow({ status: 'error', provider, errorMessage: `Could not start sign-in: ${message}` })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pollOAuth(provider: OAuthProvider, start: OAuthStartResponse) {
|
|
|
|
|
if (start.flow !== 'device_code') {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const resp = await pollOAuthSession(provider.id, start.session_id)
|
|
|
|
|
|
|
|
|
|
if (resp.status === 'approved') {
|
|
|
|
|
clearPollTimer()
|
|
|
|
|
await finalizeSuccess(provider.name)
|
|
|
|
|
} else if (resp.status !== 'pending') {
|
|
|
|
|
clearPollTimer()
|
|
|
|
|
setFlow({
|
|
|
|
|
status: 'error',
|
|
|
|
|
provider,
|
|
|
|
|
start,
|
|
|
|
|
errorMessage: resp.error_message || `Sign-in ${resp.status}.`
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
clearPollTimer()
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
|
|
|
setFlow({ status: 'error', provider, start, errorMessage: `Polling failed: ${message}` })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitPkce() {
|
|
|
|
|
if (flow.status !== 'awaiting_user' || !flow.provider || flow.start?.flow !== 'pkce') {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const code = (flow.submitCode || '').trim()
|
|
|
|
|
|
|
|
|
|
if (!code) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const provider = flow.provider
|
|
|
|
|
const start = flow.start
|
|
|
|
|
setFlow(prev => ({ ...prev, status: 'submitting' }))
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const resp = await submitOAuthCode(provider.id, start.session_id, code)
|
|
|
|
|
|
|
|
|
|
if (resp.ok && resp.status === 'approved') {
|
|
|
|
|
await finalizeSuccess(provider.name)
|
|
|
|
|
} else {
|
|
|
|
|
setFlow({ status: 'error', provider, start, errorMessage: resp.message || 'Token exchange failed.' })
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
|
|
|
setFlow({ status: 'error', provider, start, errorMessage: message })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cancelFlow() {
|
|
|
|
|
clearPollTimer()
|
|
|
|
|
|
|
|
|
|
if (flow.start?.session_id) {
|
|
|
|
|
cancelOAuthSession(flow.start.session_id).catch(() => undefined)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setFlow({ status: 'idle' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function copyDeviceCode() {
|
|
|
|
|
if (flow.start?.flow !== 'device_code') {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(flow.start.user_code)
|
|
|
|
|
setFlow(prev => ({ ...prev, copyState: 'copied' }))
|
|
|
|
|
window.setTimeout(() => setFlow(prev => ({ ...prev, copyState: 'idle' })), 1500)
|
|
|
|
|
} catch {
|
|
|
|
|
// clipboard write blocked; user can still type the code manually
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveApiKey() {
|
|
|
|
|
const value = apiKeyValue.trim()
|
|
|
|
|
const minLen = apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 1 : 8
|
|
|
|
|
|
|
|
|
|
if (!value || value.length < minLen || apiKeySaving) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setApiKeySaving(true)
|
|
|
|
|
setApiKeyError(null)
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await setEnvVar(apiKeyOption.envKey, value)
|
|
|
|
|
await requestGateway('reload.env').catch(() => undefined)
|
|
|
|
|
const ok = await refreshSetupCheck()
|
|
|
|
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
notify({ kind: 'success', title: 'Hermes is ready', message: `${apiKeyOption.name} connected.` })
|
|
|
|
|
setProviderConfigured(true)
|
|
|
|
|
completeDesktopOnboarding()
|
|
|
|
|
setApiKeyValue('')
|
|
|
|
|
onCompleted?.()
|
|
|
|
|
} else {
|
|
|
|
|
setApiKeyError(`Saved, but Hermes still cannot reach ${apiKeyOption.name}. Double-check the value.`)
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
|
|
|
notifyError(err, `Could not save ${apiKeyOption.name}`)
|
|
|
|
|
setApiKeyError(message)
|
|
|
|
|
} finally {
|
|
|
|
|
setApiKeySaving(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!shouldShow || providerConfigured) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const oauthList = oauthProviders ?? []
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-background/80 p-6 backdrop-blur-xl">
|
|
|
|
|
<div className="w-full max-w-2xl overflow-hidden rounded-3xl border border-border bg-card/95 shadow-2xl">
|
|
|
|
|
<div className="border-b border-border bg-muted/30 px-6 py-5">
|
|
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
|
|
|
<KeyRound className="size-5" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-lg font-semibold tracking-tight">Set up Hermes</h2>
|
|
|
|
|
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
|
|
|
|
|
Add one inference provider before starting your first chat. This writes to the current Hermes
|
|
|
|
|
profile's `.env` file and takes effect immediately.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
|
|
|
<Sparkles className="size-5" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h2 className="text-lg font-semibold tracking-tight">Welcome to Hermes</h2>
|
|
|
|
|
<p className="mt-1 max-w-xl text-sm leading-6 text-muted-foreground">
|
|
|
|
|
Connect a model provider to start chatting. Most options take one click.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-5 p-6">
|
|
|
|
|
{checking ? (
|
|
|
|
|
{flow.status === 'idle' || flow.status === 'success' ? (
|
|
|
|
|
<ProviderPicker
|
|
|
|
|
apiKeyError={apiKeyError}
|
|
|
|
|
apiKeyOption={apiKeyOption}
|
|
|
|
|
apiKeySaving={apiKeySaving}
|
|
|
|
|
apiKeyValue={apiKeyValue}
|
|
|
|
|
loading={oauthProviders === null}
|
|
|
|
|
mode={mode}
|
|
|
|
|
oauthProviders={oauthList}
|
|
|
|
|
onApiKeySave={() => void saveApiKey()}
|
|
|
|
|
onApiKeySelect={setApiKeyOption}
|
|
|
|
|
onApiKeyValueChange={setApiKeyValue}
|
|
|
|
|
onModeChange={setMode}
|
|
|
|
|
onProviderSelect={provider => void startProviderFlow(provider)}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<FlowPanel
|
|
|
|
|
flow={flow}
|
|
|
|
|
onCancel={cancelFlow}
|
|
|
|
|
onCopyCode={() => void copyDeviceCode()}
|
|
|
|
|
onSubmitCode={() => void submitPkce()}
|
|
|
|
|
onSubmitCodeChange={code => setFlow(prev => ({ ...prev, submitCode: code }))}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProviderPickerProps {
|
|
|
|
|
apiKeyError: null | string
|
|
|
|
|
apiKeyOption: ApiKeyOption
|
|
|
|
|
apiKeySaving: boolean
|
|
|
|
|
apiKeyValue: string
|
|
|
|
|
loading: boolean
|
|
|
|
|
mode: 'apikey' | 'oauth'
|
|
|
|
|
oauthProviders: OAuthProvider[]
|
|
|
|
|
onApiKeySave: () => void
|
|
|
|
|
onApiKeySelect: (option: ApiKeyOption) => void
|
|
|
|
|
onApiKeyValueChange: (value: string) => void
|
|
|
|
|
onModeChange: (mode: 'apikey' | 'oauth') => void
|
|
|
|
|
onProviderSelect: (provider: OAuthProvider) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ProviderPicker({
|
|
|
|
|
apiKeyError,
|
|
|
|
|
apiKeyOption,
|
|
|
|
|
apiKeySaving,
|
|
|
|
|
apiKeyValue,
|
|
|
|
|
loading,
|
|
|
|
|
mode,
|
|
|
|
|
oauthProviders,
|
|
|
|
|
onApiKeySave,
|
|
|
|
|
onApiKeySelect,
|
|
|
|
|
onApiKeyValueChange,
|
|
|
|
|
onModeChange,
|
|
|
|
|
onProviderSelect
|
|
|
|
|
}: ProviderPickerProps) {
|
|
|
|
|
const hasOauth = oauthProviders.length > 0
|
|
|
|
|
const minLen = apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 1 : 8
|
|
|
|
|
const canSave = apiKeyValue.trim().length >= minLen
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{hasOauth && (
|
|
|
|
|
<div className="flex gap-1 rounded-full border border-border bg-muted/40 p-1 self-start text-xs font-medium">
|
|
|
|
|
<button
|
|
|
|
|
className={cn(
|
|
|
|
|
'rounded-full px-3 py-1 transition',
|
|
|
|
|
mode === 'oauth' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => onModeChange('oauth')}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
Sign in
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className={cn(
|
|
|
|
|
'rounded-full px-3 py-1 transition',
|
|
|
|
|
mode === 'apikey' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => onModeChange('apikey')}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
Use an API key
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{mode === 'oauth' && hasOauth ? (
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex items-center gap-2 rounded-2xl bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
|
|
|
|
<Loader2 className="size-4 animate-spin" />
|
|
|
|
|
Checking provider setup...
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{onboarding.reason ? (
|
|
|
|
|
<div className="flex gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
|
|
|
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
|
|
|
<span>{onboarding.reason}</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">Provider</label>
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
|
|
|
{providerOptions.map(option => (
|
|
|
|
|
<button
|
|
|
|
|
className={cn(
|
|
|
|
|
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
|
|
|
|
|
selectedKey === option.key ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
|
|
|
|
)}
|
|
|
|
|
key={option.key}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedKey(option.key)
|
|
|
|
|
setValue('')
|
|
|
|
|
}}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="text-sm font-medium">{optionLabel(option, envVars?.[option.key])}</span>
|
|
|
|
|
{selectedKey === option.key ? <Check className="size-4 text-primary" /> : null}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">{option.helper}</p>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
Looking up providers...
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
oauthProviders.map(provider => (
|
|
|
|
|
<ProviderRow key={provider.id} onSelect={onProviderSelect} provider={provider} />
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid gap-4">
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
|
|
|
{API_KEY_OPTIONS.map(option => (
|
|
|
|
|
<button
|
|
|
|
|
className={cn(
|
|
|
|
|
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
|
|
|
|
|
apiKeyOption.id === option.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
|
|
|
|
|
)}
|
|
|
|
|
key={option.id}
|
|
|
|
|
onClick={() => onApiKeySelect(option)}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="text-sm font-medium">{option.name}</span>
|
|
|
|
|
{apiKeyOption.id === option.id ? <Check className="size-4 text-primary" /> : null}
|
|
|
|
|
</div>
|
|
|
|
|
{option.short ? <p className="mt-1 text-xs text-muted-foreground">{option.short}</p> : null}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-2">
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
<label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
|
|
|
|
|
{selectedKey}
|
|
|
|
|
</label>
|
|
|
|
|
{selectedInfo?.url ? (
|
|
|
|
|
<p className="text-sm leading-6 text-muted-foreground">{apiKeyOption.description}</p>
|
|
|
|
|
{apiKeyOption.docsUrl ? (
|
|
|
|
|
<Button asChild size="xs" variant="ghost">
|
|
|
|
|
<a href={selectedInfo.url} rel="noreferrer" target="_blank">
|
|
|
|
|
Docs
|
|
|
|
|
<a href={apiKeyOption.docsUrl} rel="noreferrer" target="_blank">
|
|
|
|
|
Get a key
|
|
|
|
|
<ExternalLink className="size-3" />
|
|
|
|
|
</a>
|
|
|
|
|
</Button>
|
|
|
|
@@ -269,34 +535,212 @@ export function DesktopOnboardingOverlay({
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
autoFocus
|
|
|
|
|
className="font-mono"
|
|
|
|
|
onChange={event => setValue(event.target.value)}
|
|
|
|
|
onChange={event => onApiKeyValueChange(event.target.value)}
|
|
|
|
|
onKeyDown={event => {
|
|
|
|
|
if (event.key === 'Enter') {
|
|
|
|
|
void handleSave()
|
|
|
|
|
onApiKeySave()
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
placeholder={selectedKey === 'OPENAI_BASE_URL' ? 'http://127.0.0.1:8000/v1' : 'Paste API key'}
|
|
|
|
|
type={selectedInfo?.is_password === false || selectedKey === 'OPENAI_BASE_URL' ? 'text' : 'password'}
|
|
|
|
|
value={value}
|
|
|
|
|
placeholder={apiKeyOption.placeholder || 'Paste API key'}
|
|
|
|
|
type={apiKeyOption.envKey === 'OPENAI_BASE_URL' ? 'text' : 'password'}
|
|
|
|
|
value={apiKeyValue}
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs leading-5 text-muted-foreground">{selectedOption.helper}</p>
|
|
|
|
|
{apiKeyError ? <p className="text-xs text-destructive">{apiKeyError}</p> : null}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error ? (
|
|
|
|
|
<div className="flex gap-2 rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
|
|
|
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
|
|
|
|
<span>{error}</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button disabled={!canSave || apiKeySaving} onClick={onApiKeySave}>
|
|
|
|
|
{apiKeySaving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
|
|
|
|
{apiKeySaving ? 'Connecting' : 'Connect'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end border-t border-border pt-5">
|
|
|
|
|
<Button disabled={!canSave || saving} onClick={() => void handleSave()}>
|
|
|
|
|
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
|
|
|
|
|
{saving ? 'Saving' : 'Save and continue'}
|
|
|
|
|
function ProviderRow({
|
|
|
|
|
provider,
|
|
|
|
|
onSelect
|
|
|
|
|
}: {
|
|
|
|
|
provider: OAuthProvider
|
|
|
|
|
onSelect: (provider: OAuthProvider) => void
|
|
|
|
|
}) {
|
|
|
|
|
const isExternal = provider.flow === 'external'
|
|
|
|
|
const loggedIn = provider.status?.logged_in
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
className={cn(
|
|
|
|
|
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-4 text-left transition hover:border-primary/40 hover:bg-accent/40',
|
|
|
|
|
loggedIn && 'border-primary/30'
|
|
|
|
|
)}
|
|
|
|
|
onClick={() => onSelect(provider)}
|
|
|
|
|
type="button"
|
|
|
|
|
>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="text-sm font-semibold">Sign in with {provider.name}</span>
|
|
|
|
|
{loggedIn ? (
|
|
|
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
|
|
|
|
<Check className="size-3" />
|
|
|
|
|
Connected
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-1 text-xs leading-5 text-muted-foreground">
|
|
|
|
|
{isExternal ? `Use the ${provider.name} CLI: ${provider.cli_command}` : flowSubtitle(provider.flow)}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flowSubtitle(flow: OAuthProvider['flow']) {
|
|
|
|
|
if (flow === 'pkce') {
|
|
|
|
|
return 'Opens your browser, asks you to paste a one-time code back here.'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (flow === 'device_code') {
|
|
|
|
|
return 'Opens a verification page in your browser. Hermes connects automatically.'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface FlowPanelProps {
|
|
|
|
|
flow: FlowState
|
|
|
|
|
onCancel: () => void
|
|
|
|
|
onCopyCode: () => void
|
|
|
|
|
onSubmitCode: () => void
|
|
|
|
|
onSubmitCodeChange: (code: string) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FlowPanel({ flow, onCancel, onCopyCode, onSubmitCode, onSubmitCodeChange }: FlowPanelProps) {
|
|
|
|
|
if (flow.status === 'starting') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
|
|
|
|
<Loader2 className="size-4 animate-spin" />
|
|
|
|
|
Starting sign-in for {flow.provider?.name}...
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (flow.status === 'submitting') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
|
|
|
|
<Loader2 className="size-4 animate-spin" />
|
|
|
|
|
Verifying your code with {flow.provider?.name}...
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (flow.status === 'success') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
|
|
|
|
<Check className="size-4" />
|
|
|
|
|
{flow.provider?.name} connected. You're ready to chat.
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (flow.status === 'error') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid gap-3">
|
|
|
|
|
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
|
|
|
{flow.errorMessage || 'Sign-in failed. Try again.'}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button onClick={onCancel} variant="outline">
|
|
|
|
|
Pick a different provider
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (flow.status === 'awaiting_user' && flow.start?.flow === 'pkce' && flow.provider) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold">Sign in with {flow.provider.name}</h3>
|
|
|
|
|
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
|
|
|
|
|
<li>We opened {flow.provider.name} in your browser.</li>
|
|
|
|
|
<li>Authorize Hermes there.</li>
|
|
|
|
|
<li>Copy the authorization code and paste it below.</li>
|
|
|
|
|
</ol>
|
|
|
|
|
</div>
|
|
|
|
|
<Input
|
|
|
|
|
autoFocus
|
|
|
|
|
onChange={event => onSubmitCodeChange(event.target.value)}
|
|
|
|
|
onKeyDown={event => {
|
|
|
|
|
if (event.key === 'Enter') {
|
|
|
|
|
onSubmitCode()
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Paste authorization code"
|
|
|
|
|
value={flow.submitCode || ''}
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
<Button asChild size="xs" variant="ghost">
|
|
|
|
|
<a href={flow.start.auth_url} rel="noreferrer" target="_blank">
|
|
|
|
|
<ExternalLink className="size-3" />
|
|
|
|
|
Re-open authorization page
|
|
|
|
|
</a>
|
|
|
|
|
</Button>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button onClick={onCancel} variant="ghost">
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button disabled={!(flow.submitCode || '').trim()} onClick={onSubmitCode}>
|
|
|
|
|
Continue
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (flow.status === 'polling' && flow.start?.flow === 'device_code' && flow.provider) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid gap-4">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-sm font-semibold">Sign in with {flow.provider.name}</h3>
|
|
|
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
|
|
|
We opened {flow.provider.name} in your browser. Enter this code there:
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
|
|
|
|
|
<code className="font-mono text-2xl tracking-[0.4em]">{flow.start.user_code}</code>
|
|
|
|
|
<Button onClick={onCopyCode} size="sm" variant="outline">
|
|
|
|
|
{flow.copyState === 'copied' ? <Check className="size-4" /> : 'Copy'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
|
<Button asChild size="xs" variant="ghost">
|
|
|
|
|
<a href={flow.start.verification_url} rel="noreferrer" target="_blank">
|
|
|
|
|
<ExternalLink className="size-3" />
|
|
|
|
|
Re-open verification page
|
|
|
|
|
</a>
|
|
|
|
|
</Button>
|
|
|
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
|
|
|
<Loader2 className="size-3 animate-spin" />
|
|
|
|
|
Waiting for you to authorize...
|
|
|
|
|
</div>
|
|
|
|
|
<Button onClick={onCancel} size="sm" variant="ghost">
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
|
|
|
|
|
<Loader2 className="size-4 animate-spin" />
|
|
|
|
|
Working...
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|