diff --git a/packages/ui/src/components/model-selector.tsx b/packages/ui/src/components/model-selector.tsx index 363bfe79..fba3680d 100644 --- a/packages/ui/src/components/model-selector.tsx +++ b/packages/ui/src/components/model-selector.tsx @@ -1,11 +1,12 @@ import { Combobox } from "@kobalte/core/combobox" import { createEffect, createMemo, createSignal } from "solid-js" import { providers, fetchProviders } from "../stores/sessions" -import { ChevronDown, Star } from "lucide-solid" +import { ChevronDown, PlugZap, Star } from "lucide-solid" import type { Model } from "../types/session" import { useI18n } from "../lib/i18n" import { getLogger } from "../lib/logger" import { uiState, toggleFavoriteModelPreference } from "../stores/preferences" +import { ProviderManagerModal } from "./provider-auth/provider-manager-modal" const log = getLogger("session") interface ModelSelectorProps { @@ -52,6 +53,7 @@ export default function ModelSelector(props: ModelSelectorProps) { const [initialQuery, setInitialQuery] = createSignal("") const [initialQueryReady, setInitialQueryReady] = createSignal(false) const [inputValue, setInputValue] = createSignal("") + const [providersModalOpen, setProvidersModalOpen] = createSignal(false) let triggerRef!: HTMLButtonElement let searchInputRef!: HTMLInputElement let listboxRef!: HTMLUListElement @@ -404,6 +406,27 @@ export default function ModelSelector(props: ModelSelectorProps) { ) } diff --git a/packages/ui/src/components/provider-auth/provider-manager-modal.tsx b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx new file mode 100644 index 00000000..89cbf3b6 --- /dev/null +++ b/packages/ui/src/components/provider-auth/provider-manager-modal.tsx @@ -0,0 +1,650 @@ +import { Dialog } from "@kobalte/core/dialog" +import { Select } from "@kobalte/core/select" +import { createEffect, createMemo, createSignal, For, Show, type Component } from "solid-js" +import { Check, ChevronDown, ExternalLink, KeyRound, Loader2, PlugZap, RefreshCw, ShieldCheck, X } from "lucide-solid" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import { openExternalUrl } from "../../lib/external-url" +import { useI18n } from "../../lib/i18n" +import { requestData } from "../../lib/opencode-api" +import { isTauriHost } from "../../lib/runtime-env" +import { + extractProviderAuthErrorMessage, + genericApiMethod, + isAbortError, + shouldShowProviderAuthPrompt, + type ProviderAuthAuthorization, + type ProviderAuthMethod, +} from "../../lib/provider-auth" +import { instances } from "../../stores/instances" +import { fetchProviders } from "../../stores/sessions" + +type AuthStage = "idle" | "prompts" | "authorizing" | "code" | "waiting" | "success" | "error" + +type MethodOption = { + value: string + label: string + method: ProviderAuthMethod + index: number +} + +type ConfigurableProviderOption = { + id: string + name: string + modelCount: number + connectionSummary: string +} + +type ListedProvider = { + id: string + name: string + modelCount: number + source: "env" | "config" | "custom" | "api" | "unknown" +} + +type DisconnectMode = "auth-remove" | "disable-in-config" | "not-disconnectable" | "unknown" + +interface ProviderManagerModalProps { + instanceId: string + open: boolean + onOpenChange: (open: boolean) => void +} + +function modelCountFromProvider(provider: any): number { + const models = provider?.models + if (Array.isArray(models)) return models.length + if (models && typeof models === "object") return Object.keys(models).length + return 0 +} + +export const ProviderManagerModal: Component = (props) => { + const { t } = useI18n() + const [methodsByProvider, setMethodsByProvider] = createSignal>({}) + const [availableProviders, setAvailableProviders] = createSignal([]) + const [connectedProviderIds, setConnectedProviderIds] = createSignal>(new Set()) + const [configuredProviderIds, setConfiguredProviderIds] = createSignal>(new Set()) + const [configData, setConfigData] = createSignal>({}) + const [selectedProviderId, setSelectedProviderId] = createSignal(null) + const [activeProviderId, setActiveProviderId] = createSignal(null) + const [selectedMethodIndex, setSelectedMethodIndex] = createSignal(0) + const [apiKey, setApiKey] = createSignal("") + const [promptValues, setPromptValues] = createSignal>({}) + const [authorization, setAuthorization] = createSignal(null) + const [code, setCode] = createSignal("") + const [stage, setStage] = createSignal("idle") + const [loading, setLoading] = createSignal(false) + const [loadError, setLoadError] = createSignal(null) + const [actionError, setActionError] = createSignal(null) + const [authorizationLaunchBlocked, setAuthorizationLaunchBlocked] = createSignal(false) + const [authorizationLinkCopied, setAuthorizationLinkCopied] = createSignal(false) + let callbackAbortController: AbortController | null = null + let pendingOauthPopup: Window | null = null + + const instance = createMemo(() => instances().get(props.instanceId) ?? null) + const client = createMemo(() => instance()?.client ?? null) + + const providerNameById = createMemo(() => { + const names = new Map() + for (const provider of availableProviders()) { + names.set(provider.id, provider.name || provider.id) + } + return names + }) + + const configurableProviders = createMemo(() => { + const ids = new Set() + for (const provider of availableProviders()) ids.add(provider.id) + for (const id of Object.keys(methodsByProvider())) ids.add(id) + return Array.from(ids) + .sort((left, right) => left.localeCompare(right, undefined, { sensitivity: "base" })) + .map((id) => { + const listed = availableProviders().find((provider) => provider.id === id) + return { + id, + name: providerNameById().get(id) ?? id, + modelCount: listed?.modelCount ?? 0, + connectionSummary: methodSummary(id), + } + }) + }) + + const configuredProviders = createMemo(() => + availableProviders().filter((provider) => isConfiguredProvider(provider)), + ) + + const getDisconnectMode = (provider: ListedProvider): DisconnectMode => { + if (provider.source === "env") return "not-disconnectable" + if (provider.source === "config" || configuredProviderIds().has(provider.id)) return "disable-in-config" + if (provider.source === "api" || provider.source === "custom") return "auth-remove" + return "unknown" + } + + const isConfiguredProvider = (provider: ListedProvider) => + connectedProviderIds().has(provider.id) || + provider.source === "env" || + provider.source === "config" || + provider.source === "api" || + configuredProviderIds().has(provider.id) + + const describeProviderSource = (provider: ListedProvider) => { + const mode = getDisconnectMode(provider) + if (mode === "disable-in-config") return t("settings.providers.source.config") + if (mode === "not-disconnectable") return t("settings.providers.source.env") + if (provider.source === "api") return t("settings.providers.source.api") + if (provider.source === "custom") return t("settings.providers.source.custom") + return t("settings.providers.source.unknown") + } + + const selectedProviderOption = createMemo(() => + configurableProviders().find((provider) => provider.id === selectedProviderId()) ?? configurableProviders()[0] ?? null, + ) + + const activeProviderName = createMemo(() => { + const providerId = activeProviderId() + return providerId ? providerNameById().get(providerId) ?? providerId : "" + }) + + const activeMethods = createMemo(() => { + const providerId = activeProviderId() + if (!providerId) return [genericApiMethod] + const methods = methodsByProvider()[providerId] + return methods && methods.length > 0 ? methods : [genericApiMethod] + }) + + const methodOptions = createMemo(() => + activeMethods().map((method, index) => ({ + value: String(index), + label: method.label || (method.type === "oauth" ? t("settings.providers.method.oauth") : t("settings.providers.method.api")), + method, + index, + })), + ) + + const selectedMethodOption = createMemo(() => methodOptions().find((option) => option.index === selectedMethodIndex()) ?? methodOptions()[0]) + const selectedMethod = createMemo(() => selectedMethodOption()?.method ?? genericApiMethod) + const oauthPromptsUnsupported = createMemo(() => selectedMethod().type === "oauth" && (selectedMethod().prompts?.length ?? 0) > 0) + const visiblePrompts = createMemo(() => + (selectedMethod().prompts ?? []).filter((prompt) => shouldShowProviderAuthPrompt(prompt, promptValues())), + ) + const canSubmit = createMemo(() => { + if (!activeProviderId()) return false + if (stage() === "authorizing" || stage() === "waiting" || stage() === "success") return false + if (oauthPromptsUnsupported()) return false + if (selectedMethod().type === "api") return apiKey().trim().length > 0 + return visiblePrompts().every((prompt) => (promptValues()[prompt.key] ?? "").trim().length > 0) + }) + + function handleModalOpenChange(open: boolean) { + if (!open) resetFlow(null) + props.onOpenChange(open) + } + + function isBrowserHostForOAuth(): boolean { + return !isTauriHost() && typeof window !== "undefined" + } + + function prepareOAuthPopupWindow(): Window | null { + if (!isBrowserHostForOAuth()) { + return null + } + + try { + const popup = window.open("", "_blank") + if (popup && popup.document) { + popup.document.title = t("settings.providers.oauth.popup.loadingTitle") + popup.document.body.innerHTML = `
${t("settings.providers.oauth.popup.loadingBody")}
` + } + return popup + } catch { + return null + } + } + + async function launchAuthorizationUrl(url: string, options?: { popup?: Window | null; sameTab?: boolean }): Promise { + if (options?.sameTab && typeof window !== "undefined") { + window.location.assign(url) + return true + } + + const popup = options?.popup + if (popup && !popup.closed) { + try { + popup.location.href = url + return true + } catch { + // fall through to general opener path + } + } + + return await openExternalUrl(url, "provider-auth") + } + + async function copyAuthorizationUrl(): Promise { + const url = authorization()?.url + if (!url || typeof navigator === "undefined" || !navigator.clipboard?.writeText) { + return + } + + try { + await navigator.clipboard.writeText(url) + setAuthorizationLinkCopied(true) + setTimeout(() => setAuthorizationLinkCopied(false), 1500) + } catch { + setAuthorizationLinkCopied(false) + } + } + + createEffect(() => { + if (!props.open) return + const authClient = client() + if (!authClient) return + void loadProviderData(authClient) + }) + + async function loadProviderData(authClient: OpencodeClient): Promise { + setLoading(true) + setLoadError(null) + try { + const [providerListResponse, authResponse, configResponse] = await Promise.all([ + (authClient as any).provider.list(), + (authClient as any).provider.auth(), + (authClient as any).config.get(), + ]) + const nextConfigData = (configResponse?.data ?? {}) as Record + const nextConfiguredIds = new Set(Object.keys((nextConfigData.provider ?? {}) as Record)) + const listed = ((providerListResponse?.data?.all ?? []) as any[]).map((provider) => ({ + id: String(provider.id ?? ""), + name: String(provider.name ?? provider.id ?? ""), + modelCount: modelCountFromProvider(provider), + source: + provider?.source === "env" || provider?.source === "config" || provider?.source === "custom" || provider?.source === "api" + ? provider.source + : "unknown", + })).filter((provider) => provider.id.length > 0) + setAvailableProviders(listed) + setConnectedProviderIds(new Set((providerListResponse?.data?.connected ?? []) as string[])) + setConfiguredProviderIds(nextConfiguredIds) + setConfigData(nextConfigData) + setMethodsByProvider((authResponse?.data ?? {}) as Record) + setSelectedProviderId((current) => current ?? listed[0]?.id ?? Object.keys(authResponse?.data ?? {})[0] ?? null) + } catch (error) { + setLoadError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.loadFailed"))) + } finally { + setLoading(false) + } + } + + function resetFlow(nextProviderId: string | null = null) { + callbackAbortController?.abort() + callbackAbortController = null + if (pendingOauthPopup && !pendingOauthPopup.closed) { + pendingOauthPopup.close() + } + pendingOauthPopup = null + setActiveProviderId(nextProviderId) + setSelectedMethodIndex(0) + setApiKey("") + setPromptValues({}) + setAuthorization(null) + setCode("") + setStage(nextProviderId ? "prompts" : "idle") + setActionError(null) + setAuthorizationLaunchBlocked(false) + setAuthorizationLinkCopied(false) + } + + function updatePromptValue(key: string, value: string) { + setPromptValues((current) => ({ ...current, [key]: value })) + } + + async function refreshAfterAuth(authClient: OpencodeClient) { + await (authClient as any).global.dispose().catch(() => undefined) + await fetchProviders(props.instanceId).catch(() => undefined) + await loadProviderData(authClient).catch(() => undefined) + } + + async function submitApiAuth(providerId: string, authClient: OpencodeClient) { + await requestData( + (authClient as any).auth.set({ providerID: providerId, auth: { type: "api", key: apiKey().trim() } }), + "auth.set", + ) + await refreshAfterAuth(authClient) + resetFlow(null) + } + + async function submitOAuthAuthorize(providerId: string, authClient: OpencodeClient) { + const response = await (authClient as any).provider.oauth.authorize( + { providerID: providerId, method: selectedMethodIndex() }, + { throwOnError: true }, + ) + const data = response?.data as ProviderAuthAuthorization | undefined + if (!data) throw new Error(t("settings.providers.errors.noAuthorization")) + setAuthorization(data) + const opened = await launchAuthorizationUrl(data.url, { popup: pendingOauthPopup }) + pendingOauthPopup = null + setAuthorizationLaunchBlocked(!opened) + if (data.method === "code") { + setStage("code") + return + } + setStage("waiting") + callbackAbortController = new AbortController() + await requestData( + (authClient as any).provider.oauth.callback( + { providerID: providerId, method: selectedMethodIndex() }, + { signal: callbackAbortController.signal }, + ), + "provider.oauth.callback", + ) + callbackAbortController = null + await refreshAfterAuth(authClient) + resetFlow(null) + } + + async function submitAuth() { + const providerId = activeProviderId() + const authClient = client() + if (!providerId || !authClient || !canSubmit()) return + setStage("authorizing") + setActionError(null) + try { + if (selectedMethod().type === "api") { + await submitApiAuth(providerId, authClient) + return + } + pendingOauthPopup = prepareOAuthPopupWindow() + setAuthorizationLaunchBlocked(isBrowserHostForOAuth() && pendingOauthPopup === null) + await submitOAuthAuthorize(providerId, authClient) + } catch (error) { + if (pendingOauthPopup && !pendingOauthPopup.closed) { + pendingOauthPopup.close() + } + pendingOauthPopup = null + if (isAbortError(error)) { + setStage("prompts") + return + } + setActionError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.authorizationFailed"))) + setStage("error") + } + } + + async function submitOAuthCode() { + const providerId = activeProviderId() + const authClient = client() + if (!providerId || !authClient || !code().trim()) return + setStage("authorizing") + setActionError(null) + try { + await requestData( + (authClient as any).provider.oauth.callback({ providerID: providerId, method: selectedMethodIndex(), code: code().trim() }), + "provider.oauth.callback", + ) + await refreshAfterAuth(authClient) + resetFlow(null) + } catch (error) { + setActionError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.authorizationFailed"))) + setStage("code") + } + } + + async function disconnectProvider(providerId: string) { + const authClient = client() + const provider = availableProviders().find((item) => item.id === providerId) + if (!authClient || !provider) return + setActionError(null) + setStage("authorizing") + try { + const disconnectMode = getDisconnectMode(provider) + if (disconnectMode === "not-disconnectable") { + setActionError(t("settings.providers.errors.envDisconnectUnavailable")) + setStage("error") + return + } + + if (disconnectMode === "disable-in-config") { + const disabledProviders = Array.isArray(configData().disabled_providers) + ? [...configData().disabled_providers] + : [] + if (!disabledProviders.includes(providerId)) { + disabledProviders.push(providerId) + } + await requestData( + (authClient as any).config.update({ + config: { + ...configData(), + disabled_providers: disabledProviders, + }, + }), + "config.update", + ) + } else { + await requestData((authClient as any).auth.remove({ providerID: providerId }), "auth.remove") + } + await refreshAfterAuth(authClient) + resetFlow(null) + } catch (error) { + setActionError(extractProviderAuthErrorMessage(error, t("settings.providers.errors.removeFailed"))) + setStage("error") + } + } + + function cancelOAuthWait() { + callbackAbortController?.abort() + callbackAbortController = null + setStage("prompts") + setAuthorization(null) + setActionError(null) + } + + function methodSummary(providerId: string) { + const methods = methodsByProvider()[providerId] + if (!methods || methods.length === 0) return t("settings.providers.method.fallback") + const kinds = new Set(methods.map((method) => method.type)) + if (kinds.size > 1) return t("settings.providers.method.mixed") + if (kinds.has("oauth")) return t("settings.providers.method.oauth") + return t("settings.providers.method.api") + } + + return ( + + + + +
+
+ +
+ {t("settings.providers.title")} +

{t("settings.providers.subtitle")}

+
+
+ +
+ +
+ +
{t("settings.providers.empty.noInstance")}
+
+ + +
+ + value={selectedProviderOption()} + onChange={(option) => option && setSelectedProviderId(option.id)} + options={configurableProviders()} + optionValue="id" + optionTextValue="name" + itemComponent={(itemProps) => ( + +
+ {itemProps.item.rawValue.name} +
+ {itemProps.item.rawValue.id} + + + {itemProps.item.rawValue.modelCount === 1 + ? t("settings.providers.models.one", { count: itemProps.item.rawValue.modelCount }) + : t("settings.providers.models.other", { count: itemProps.item.rawValue.modelCount })} + + + {itemProps.item.rawValue.connectionSummary} +
+
+
+ )} + > + +
+ > + {(state) => ( +
+ {state.selectedOption()?.name ?? t("settings.providers.selectProvider")} + + + {state.selectedOption()?.id} • {state.selectedOption()?.connectionSummary} + + +
+ )} + +
+ +
+ + + + +
+ + +
{loadError()}
+
+ + +
+
+
+

{t("settings.providers.auth.title", { provider: activeProviderName() })}

+

{t("settings.providers.auth.subtitle")}

+
+ +
+ + 1}> +
+
{t("settings.providers.method.title")}
{t("settings.providers.method.subtitle")}
+ + value={selectedMethodOption()} + onChange={(option) => { + if (!option) return + setSelectedMethodIndex(option.index) + setPromptValues({}) + setApiKey("") + setAuthorization(null) + setCode("") + setStage("prompts") + setActionError(null) + }} + options={methodOptions()} + optionValue="value" + optionTextValue="label" + itemComponent={(itemProps) => {itemProps.item.rawValue.label}} + > + +
>{(state) => {state.selectedOption()?.label}}
+ +
+ + +
+
+ + +
+
+ + +
+ +
{t("settings.providers.oauth.promptsUnsupported")}
+
+
{t("settings.providers.oauth.noPrompts")}
+ {(prompt) => ( +
+ + updatePromptValue(prompt.key, event.currentTarget.value)} placeholder={prompt.type === "text" ? prompt.placeholder : undefined} />}> + value={(prompt.type === "select" ? prompt.options : []).find((option) => option.value === promptValues()[prompt.key])} onChange={(option) => option && updatePromptValue(prompt.key, option.value)} options={prompt.type === "select" ? prompt.options : []} optionValue="value" optionTextValue="label" itemComponent={(itemProps) => {itemProps.item.rawValue.label}{itemProps.item.rawValue.hint}}> +
>{(state) => {state.selectedOption()?.label ?? t("settings.providers.prompt.selectPlaceholder")}}
+ + +
+
+ )}
+
+
+ +
{authorization()?.instructions || t("settings.providers.oauth.enterCode")}
+
{t("settings.providers.oauth.waitingTitle")}
{authorization()?.instructions}
+ +
+ + + {t("settings.providers.oauth.openPage")} + + + +
+
+ +
{t("settings.providers.oauth.popupBlocked")}
+
+
{t("settings.providers.success")}
+
{actionError()}
+ +
+ void submitAuth()}>{t("settings.providers.actions.working")}}> + + +
+
+
+ +
+

{t("settings.providers.configured.title")}

+
{t("settings.providers.loading")}
+
{t("settings.providers.empty.noConfiguredProviders")}
+
+ {(provider) => ( +
+

{provider.name || provider.id}

{provider.id}

{methodSummary(provider.id)}

{describeProviderSource(provider)}

+ +
+ )}
+
+
+
+
+
+
+
+ ) +} diff --git a/packages/ui/src/lib/external-url.ts b/packages/ui/src/lib/external-url.ts index b0e7ca8b..f9e7547e 100644 --- a/packages/ui/src/lib/external-url.ts +++ b/packages/ui/src/lib/external-url.ts @@ -1,23 +1,25 @@ import { isTauriHost } from "./runtime-env" -export async function openExternalUrl(url: string, context = "ui"): Promise { +export async function openExternalUrl(url: string, context = "ui"): Promise { if (typeof window === "undefined") { - return + return false } if (isTauriHost()) { try { const { openUrl } = await import("@tauri-apps/plugin-opener") await openUrl(url) - return + return true } catch (error) { console.warn(`[${context}] unable to open via system opener`, error) } } try { - window.open(url, "_blank", "noopener,noreferrer") + const popup = window.open(url, "_blank", "noopener,noreferrer") + return popup !== null } catch (error) { console.warn(`[${context}] unable to open external url`, error) + return false } } diff --git a/packages/ui/src/lib/i18n/messages/en/settings.ts b/packages/ui/src/lib/i18n/messages/en/settings.ts index 79c27144..2a7d86a8 100644 --- a/packages/ui/src/lib/i18n/messages/en/settings.ts +++ b/packages/ui/src/lib/i18n/messages/en/settings.ts @@ -32,6 +32,7 @@ export const settingsMessages = { "modelSelector.favoritesOnly.showAll": "Show all models", "modelSelector.favorite.add": "Add to favorites", "modelSelector.favorite.remove": "Remove from favorites", + "modelSelector.manageProviders": "Manage Providers", "thinkingSelector.variant.default": "Default", "thinkingSelector.label": "Thinking: {variant}", @@ -70,6 +71,7 @@ export const settingsMessages = { "settings.nav.notifications": "Notifications", "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", + "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", "settings.nav.info": "Info", "settings.scope.device": "This device", @@ -127,6 +129,63 @@ export const settingsMessages = { "settings.opencode.logLevel.option.warn": "Warn", "settings.opencode.logLevel.option.error": "Error", + "settings.providers.title": "Provider Authentication", + "settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.", + "settings.providers.refresh": "Refresh", + "settings.providers.selectProvider": "Select provider", + "settings.providers.available.title": "Available Providers", + "settings.providers.configured.title": "Configured Providers", + "settings.providers.loading": "Loading provider authentication methods...", + "settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.", + "settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.", + "settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.", + "settings.providers.models.one": "{count} model", + "settings.providers.models.other": "{count} models", + "settings.providers.actions.connect": "Connect", + "settings.providers.actions.disconnect": "Disconnect", + "settings.providers.actions.remove": "Remove saved auth", + "settings.providers.actions.close": "Close", + "settings.providers.actions.continue": "Continue", + "settings.providers.actions.working": "Working...", + "settings.providers.method.title": "Authentication method", + "settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.", + "settings.providers.method.api": "API key", + "settings.providers.method.oauth": "OAuth", + "settings.providers.method.mixed": "API key and OAuth", + "settings.providers.method.fallback": "API key", + "settings.providers.status.available": "Available", + "settings.providers.auth.title": "Connect {provider}", + "settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.", + "settings.providers.apiKey.label": "API key", + "settings.providers.apiKey.placeholder": "Paste provider API key", + "settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.", + "settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.", + "settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.", + "settings.providers.oauth.openPage": "Open authorization page", + "settings.providers.oauth.openHere": "Open in this tab", + "settings.providers.oauth.copyLink": "Copy link", + "settings.providers.oauth.linkCopied": "Link copied", + "settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.", + "settings.providers.oauth.popup.loadingTitle": "OpenCode authorization", + "settings.providers.oauth.popup.loadingBody": "Preparing authorization page...", + "settings.providers.oauth.codeLabel": "Authorization code", + "settings.providers.oauth.codePlaceholder": "Paste code from provider", + "settings.providers.oauth.submitCode": "Submit code", + "settings.providers.oauth.waitingTitle": "Waiting for authorization", + "settings.providers.oauth.cancelWait": "Cancel", + "settings.providers.prompt.selectPlaceholder": "Select an option", + "settings.providers.success": "Provider authentication saved. Provider data has been refreshed.", + "settings.providers.source.api": "Saved provider authentication", + "settings.providers.source.config": "Configured in opencode.jsonc", + "settings.providers.source.custom": "Configured by custom provider logic", + "settings.providers.source.env": "Configured from environment variables", + "settings.providers.source.unknown": "Configured provider source", + "settings.providers.errors.loadFailed": "Failed to load provider authentication methods.", + "settings.providers.errors.noAuthorization": "Provider did not return authorization data.", + "settings.providers.errors.authorizationFailed": "Authorization failed.", + "settings.providers.errors.removeFailed": "Failed to remove provider authentication.", + "settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.", + "settings.appearance.behavior.title": "Interaction", "settings.appearance.behavior.subtitle": "Message, diff, and input defaults.", diff --git a/packages/ui/src/lib/i18n/messages/es/settings.ts b/packages/ui/src/lib/i18n/messages/es/settings.ts index 44a6bacd..c17306bc 100644 --- a/packages/ui/src/lib/i18n/messages/es/settings.ts +++ b/packages/ui/src/lib/i18n/messages/es/settings.ts @@ -32,6 +32,7 @@ export const settingsMessages = { "modelSelector.favoritesOnly.showAll": "Mostrar todos los modelos", "modelSelector.favorite.add": "Agregar a favoritos", "modelSelector.favorite.remove": "Quitar de favoritos", + "modelSelector.manageProviders": "Manage Providers", "thinkingSelector.variant.default": "Por defecto", "thinkingSelector.label": "Pensamiento: {variant}", @@ -70,6 +71,7 @@ export const settingsMessages = { "settings.nav.notifications": "Notifications", "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", + "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", "settings.nav.info": "Info", "settings.scope.device": "This device", @@ -127,6 +129,63 @@ export const settingsMessages = { "settings.opencode.logLevel.option.warn": "Advertencia", "settings.opencode.logLevel.option.error": "Error", + "settings.providers.title": "Provider Authentication", + "settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.", + "settings.providers.refresh": "Refresh", + "settings.providers.selectProvider": "Select provider", + "settings.providers.available.title": "Available Providers", + "settings.providers.configured.title": "Configured Providers", + "settings.providers.loading": "Loading provider authentication methods...", + "settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.", + "settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.", + "settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.", + "settings.providers.models.one": "{count} model", + "settings.providers.models.other": "{count} models", + "settings.providers.actions.connect": "Connect", + "settings.providers.actions.disconnect": "Disconnect", + "settings.providers.actions.remove": "Remove saved auth", + "settings.providers.actions.close": "Close", + "settings.providers.actions.continue": "Continue", + "settings.providers.actions.working": "Working...", + "settings.providers.method.title": "Authentication method", + "settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.", + "settings.providers.method.api": "API key", + "settings.providers.method.oauth": "OAuth", + "settings.providers.method.mixed": "API key and OAuth", + "settings.providers.method.fallback": "API key", + "settings.providers.status.available": "Available", + "settings.providers.auth.title": "Connect {provider}", + "settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.", + "settings.providers.apiKey.label": "API key", + "settings.providers.apiKey.placeholder": "Paste provider API key", + "settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.", + "settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.", + "settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.", + "settings.providers.oauth.openPage": "Open authorization page", + "settings.providers.oauth.openHere": "Open in this tab", + "settings.providers.oauth.copyLink": "Copy link", + "settings.providers.oauth.linkCopied": "Link copied", + "settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.", + "settings.providers.oauth.popup.loadingTitle": "OpenCode authorization", + "settings.providers.oauth.popup.loadingBody": "Preparing authorization page...", + "settings.providers.oauth.codeLabel": "Authorization code", + "settings.providers.oauth.codePlaceholder": "Paste code from provider", + "settings.providers.oauth.submitCode": "Submit code", + "settings.providers.oauth.waitingTitle": "Waiting for authorization", + "settings.providers.oauth.cancelWait": "Cancel", + "settings.providers.prompt.selectPlaceholder": "Select an option", + "settings.providers.success": "Provider authentication saved. Provider data has been refreshed.", + "settings.providers.source.api": "Saved provider authentication", + "settings.providers.source.config": "Configured in opencode.jsonc", + "settings.providers.source.custom": "Configured by custom provider logic", + "settings.providers.source.env": "Configured from environment variables", + "settings.providers.source.unknown": "Configured provider source", + "settings.providers.errors.loadFailed": "Failed to load provider authentication methods.", + "settings.providers.errors.noAuthorization": "Provider did not return authorization data.", + "settings.providers.errors.authorizationFailed": "Authorization failed.", + "settings.providers.errors.removeFailed": "Failed to remove provider authentication.", + "settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.", + "settings.appearance.behavior.title": "Interaccion", "settings.appearance.behavior.subtitle": "Valores predeterminados de mensajes, diffs y entrada.", "settings.behavior.keyboardHints.title": "Sugerencias de atajos de teclado", diff --git a/packages/ui/src/lib/i18n/messages/fr/settings.ts b/packages/ui/src/lib/i18n/messages/fr/settings.ts index a462271f..a3b13d97 100644 --- a/packages/ui/src/lib/i18n/messages/fr/settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr/settings.ts @@ -32,6 +32,7 @@ export const settingsMessages = { "modelSelector.favoritesOnly.showAll": "Afficher tous les modèles", "modelSelector.favorite.add": "Ajouter aux favoris", "modelSelector.favorite.remove": "Retirer des favoris", + "modelSelector.manageProviders": "Manage Providers", "thinkingSelector.variant.default": "Par défaut", "thinkingSelector.label": "Réflexion : {variant}", @@ -70,6 +71,7 @@ export const settingsMessages = { "settings.nav.notifications": "Notifications", "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", + "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", "settings.nav.info": "Info", "settings.scope.device": "This device", @@ -127,6 +129,63 @@ export const settingsMessages = { "settings.opencode.logLevel.option.warn": "Avertissement", "settings.opencode.logLevel.option.error": "Erreur", + "settings.providers.title": "Provider Authentication", + "settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.", + "settings.providers.refresh": "Refresh", + "settings.providers.selectProvider": "Select provider", + "settings.providers.available.title": "Available Providers", + "settings.providers.configured.title": "Configured Providers", + "settings.providers.loading": "Loading provider authentication methods...", + "settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.", + "settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.", + "settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.", + "settings.providers.models.one": "{count} model", + "settings.providers.models.other": "{count} models", + "settings.providers.actions.connect": "Connect", + "settings.providers.actions.disconnect": "Disconnect", + "settings.providers.actions.remove": "Remove saved auth", + "settings.providers.actions.close": "Close", + "settings.providers.actions.continue": "Continue", + "settings.providers.actions.working": "Working...", + "settings.providers.method.title": "Authentication method", + "settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.", + "settings.providers.method.api": "API key", + "settings.providers.method.oauth": "OAuth", + "settings.providers.method.mixed": "API key and OAuth", + "settings.providers.method.fallback": "API key", + "settings.providers.status.available": "Available", + "settings.providers.auth.title": "Connect {provider}", + "settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.", + "settings.providers.apiKey.label": "API key", + "settings.providers.apiKey.placeholder": "Paste provider API key", + "settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.", + "settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.", + "settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.", + "settings.providers.oauth.openPage": "Open authorization page", + "settings.providers.oauth.openHere": "Open in this tab", + "settings.providers.oauth.copyLink": "Copy link", + "settings.providers.oauth.linkCopied": "Link copied", + "settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.", + "settings.providers.oauth.popup.loadingTitle": "OpenCode authorization", + "settings.providers.oauth.popup.loadingBody": "Preparing authorization page...", + "settings.providers.oauth.codeLabel": "Authorization code", + "settings.providers.oauth.codePlaceholder": "Paste code from provider", + "settings.providers.oauth.submitCode": "Submit code", + "settings.providers.oauth.waitingTitle": "Waiting for authorization", + "settings.providers.oauth.cancelWait": "Cancel", + "settings.providers.prompt.selectPlaceholder": "Select an option", + "settings.providers.success": "Provider authentication saved. Provider data has been refreshed.", + "settings.providers.source.api": "Saved provider authentication", + "settings.providers.source.config": "Configured in opencode.jsonc", + "settings.providers.source.custom": "Configured by custom provider logic", + "settings.providers.source.env": "Configured from environment variables", + "settings.providers.source.unknown": "Configured provider source", + "settings.providers.errors.loadFailed": "Failed to load provider authentication methods.", + "settings.providers.errors.noAuthorization": "Provider did not return authorization data.", + "settings.providers.errors.authorizationFailed": "Authorization failed.", + "settings.providers.errors.removeFailed": "Failed to remove provider authentication.", + "settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.", + "settings.appearance.behavior.title": "Interaction", "settings.appearance.behavior.subtitle": "Parametres par defaut pour les messages, les diffs et la saisie.", "settings.behavior.keyboardHints.title": "Indications de raccourcis clavier", diff --git a/packages/ui/src/lib/i18n/messages/he/settings.ts b/packages/ui/src/lib/i18n/messages/he/settings.ts index b0c0e7f9..8b908443 100644 --- a/packages/ui/src/lib/i18n/messages/he/settings.ts +++ b/packages/ui/src/lib/i18n/messages/he/settings.ts @@ -32,6 +32,7 @@ export const settingsMessages = { "modelSelector.favoritesOnly.showAll": "הצג את כל המודלים", "modelSelector.favorite.add": "הוסף למועדפים", "modelSelector.favorite.remove": "הסר ממועדפים", + "modelSelector.manageProviders": "Manage Providers", "thinkingSelector.variant.default": "ברירת מחדל", "thinkingSelector.label": "חשיבה: {variant}", @@ -69,6 +70,7 @@ export const settingsMessages = { "settings.nav.appearance": "מראה", "settings.nav.notifications": "התראות", "settings.nav.remote": "גישה מרוחקת", + "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", "settings.nav.info": "Info", "settings.scope.device": "מכשיר זה", @@ -126,6 +128,63 @@ export const settingsMessages = { "settings.opencode.logLevel.option.warn": "אזהרה", "settings.opencode.logLevel.option.error": "שגיאה", + "settings.providers.title": "Provider Authentication", + "settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.", + "settings.providers.refresh": "Refresh", + "settings.providers.selectProvider": "Select provider", + "settings.providers.available.title": "Available Providers", + "settings.providers.configured.title": "Configured Providers", + "settings.providers.loading": "Loading provider authentication methods...", + "settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.", + "settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.", + "settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.", + "settings.providers.models.one": "{count} model", + "settings.providers.models.other": "{count} models", + "settings.providers.actions.connect": "Connect", + "settings.providers.actions.disconnect": "Disconnect", + "settings.providers.actions.remove": "Remove saved auth", + "settings.providers.actions.close": "Close", + "settings.providers.actions.continue": "Continue", + "settings.providers.actions.working": "Working...", + "settings.providers.method.title": "Authentication method", + "settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.", + "settings.providers.method.api": "API key", + "settings.providers.method.oauth": "OAuth", + "settings.providers.method.mixed": "API key and OAuth", + "settings.providers.method.fallback": "API key", + "settings.providers.status.available": "Available", + "settings.providers.auth.title": "Connect {provider}", + "settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.", + "settings.providers.apiKey.label": "API key", + "settings.providers.apiKey.placeholder": "Paste provider API key", + "settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.", + "settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.", + "settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.", + "settings.providers.oauth.openPage": "Open authorization page", + "settings.providers.oauth.openHere": "Open in this tab", + "settings.providers.oauth.copyLink": "Copy link", + "settings.providers.oauth.linkCopied": "Link copied", + "settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.", + "settings.providers.oauth.popup.loadingTitle": "OpenCode authorization", + "settings.providers.oauth.popup.loadingBody": "Preparing authorization page...", + "settings.providers.oauth.codeLabel": "Authorization code", + "settings.providers.oauth.codePlaceholder": "Paste code from provider", + "settings.providers.oauth.submitCode": "Submit code", + "settings.providers.oauth.waitingTitle": "Waiting for authorization", + "settings.providers.oauth.cancelWait": "Cancel", + "settings.providers.prompt.selectPlaceholder": "Select an option", + "settings.providers.success": "Provider authentication saved. Provider data has been refreshed.", + "settings.providers.source.api": "Saved provider authentication", + "settings.providers.source.config": "Configured in opencode.jsonc", + "settings.providers.source.custom": "Configured by custom provider logic", + "settings.providers.source.env": "Configured from environment variables", + "settings.providers.source.unknown": "Configured provider source", + "settings.providers.errors.loadFailed": "Failed to load provider authentication methods.", + "settings.providers.errors.noAuthorization": "Provider did not return authorization data.", + "settings.providers.errors.authorizationFailed": "Authorization failed.", + "settings.providers.errors.removeFailed": "Failed to remove provider authentication.", + "settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.", + "settings.appearance.behavior.title": "אינטראקציה", "settings.appearance.behavior.subtitle": "ברירות מחדל להודעות, diff וקלט.", "settings.behavior.keyboardHints.title": "רמזי קיצורי מקלדת", diff --git a/packages/ui/src/lib/i18n/messages/ja/settings.ts b/packages/ui/src/lib/i18n/messages/ja/settings.ts index c69dd402..eefe1569 100644 --- a/packages/ui/src/lib/i18n/messages/ja/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja/settings.ts @@ -32,6 +32,7 @@ export const settingsMessages = { "modelSelector.favoritesOnly.showAll": "すべてのモデルを表示", "modelSelector.favorite.add": "お気に入りに追加", "modelSelector.favorite.remove": "お気に入りから削除", + "modelSelector.manageProviders": "Manage Providers", "thinkingSelector.variant.default": "デフォルト", "thinkingSelector.label": "思考: {variant}", @@ -70,6 +71,7 @@ export const settingsMessages = { "settings.nav.notifications": "Notifications", "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", + "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", "settings.nav.info": "Info", "settings.scope.device": "This device", @@ -127,6 +129,63 @@ export const settingsMessages = { "settings.opencode.logLevel.option.warn": "警告", "settings.opencode.logLevel.option.error": "エラー", + "settings.providers.title": "Provider Authentication", + "settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.", + "settings.providers.refresh": "Refresh", + "settings.providers.selectProvider": "Select provider", + "settings.providers.available.title": "Available Providers", + "settings.providers.configured.title": "Configured Providers", + "settings.providers.loading": "Loading provider authentication methods...", + "settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.", + "settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.", + "settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.", + "settings.providers.models.one": "{count} model", + "settings.providers.models.other": "{count} models", + "settings.providers.actions.connect": "Connect", + "settings.providers.actions.disconnect": "Disconnect", + "settings.providers.actions.remove": "Remove saved auth", + "settings.providers.actions.close": "Close", + "settings.providers.actions.continue": "Continue", + "settings.providers.actions.working": "Working...", + "settings.providers.method.title": "Authentication method", + "settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.", + "settings.providers.method.api": "API key", + "settings.providers.method.oauth": "OAuth", + "settings.providers.method.mixed": "API key and OAuth", + "settings.providers.method.fallback": "API key", + "settings.providers.status.available": "Available", + "settings.providers.auth.title": "Connect {provider}", + "settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.", + "settings.providers.apiKey.label": "API key", + "settings.providers.apiKey.placeholder": "Paste provider API key", + "settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.", + "settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.", + "settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.", + "settings.providers.oauth.openPage": "Open authorization page", + "settings.providers.oauth.openHere": "Open in this tab", + "settings.providers.oauth.copyLink": "Copy link", + "settings.providers.oauth.linkCopied": "Link copied", + "settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.", + "settings.providers.oauth.popup.loadingTitle": "OpenCode authorization", + "settings.providers.oauth.popup.loadingBody": "Preparing authorization page...", + "settings.providers.oauth.codeLabel": "Authorization code", + "settings.providers.oauth.codePlaceholder": "Paste code from provider", + "settings.providers.oauth.submitCode": "Submit code", + "settings.providers.oauth.waitingTitle": "Waiting for authorization", + "settings.providers.oauth.cancelWait": "Cancel", + "settings.providers.prompt.selectPlaceholder": "Select an option", + "settings.providers.success": "Provider authentication saved. Provider data has been refreshed.", + "settings.providers.source.api": "Saved provider authentication", + "settings.providers.source.config": "Configured in opencode.jsonc", + "settings.providers.source.custom": "Configured by custom provider logic", + "settings.providers.source.env": "Configured from environment variables", + "settings.providers.source.unknown": "Configured provider source", + "settings.providers.errors.loadFailed": "Failed to load provider authentication methods.", + "settings.providers.errors.noAuthorization": "Provider did not return authorization data.", + "settings.providers.errors.authorizationFailed": "Authorization failed.", + "settings.providers.errors.removeFailed": "Failed to remove provider authentication.", + "settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.", + "settings.appearance.behavior.title": "操作", "settings.appearance.behavior.subtitle": "メッセージ、差分、入力の既定値。", "settings.behavior.keyboardHints.title": "キーボードショートカットのヒント", diff --git a/packages/ui/src/lib/i18n/messages/ru/settings.ts b/packages/ui/src/lib/i18n/messages/ru/settings.ts index 28fd6e23..5f841cb9 100644 --- a/packages/ui/src/lib/i18n/messages/ru/settings.ts +++ b/packages/ui/src/lib/i18n/messages/ru/settings.ts @@ -32,6 +32,7 @@ export const settingsMessages = { "modelSelector.favoritesOnly.showAll": "Показать все модели", "modelSelector.favorite.add": "Добавить в избранное", "modelSelector.favorite.remove": "Удалить из избранного", + "modelSelector.manageProviders": "Manage Providers", "thinkingSelector.variant.default": "По умолчанию", "thinkingSelector.label": "Размышления: {variant}", @@ -70,6 +71,7 @@ export const settingsMessages = { "settings.nav.notifications": "Notifications", "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", + "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", "settings.nav.info": "Info", "settings.scope.device": "This device", @@ -127,6 +129,63 @@ export const settingsMessages = { "settings.opencode.logLevel.option.warn": "Предупреждение", "settings.opencode.logLevel.option.error": "Ошибка", + "settings.providers.title": "Provider Authentication", + "settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.", + "settings.providers.refresh": "Refresh", + "settings.providers.selectProvider": "Select provider", + "settings.providers.available.title": "Available Providers", + "settings.providers.configured.title": "Configured Providers", + "settings.providers.loading": "Loading provider authentication methods...", + "settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.", + "settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.", + "settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.", + "settings.providers.models.one": "{count} model", + "settings.providers.models.other": "{count} models", + "settings.providers.actions.connect": "Connect", + "settings.providers.actions.disconnect": "Disconnect", + "settings.providers.actions.remove": "Remove saved auth", + "settings.providers.actions.close": "Close", + "settings.providers.actions.continue": "Continue", + "settings.providers.actions.working": "Working...", + "settings.providers.method.title": "Authentication method", + "settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.", + "settings.providers.method.api": "API key", + "settings.providers.method.oauth": "OAuth", + "settings.providers.method.mixed": "API key and OAuth", + "settings.providers.method.fallback": "API key", + "settings.providers.status.available": "Available", + "settings.providers.auth.title": "Connect {provider}", + "settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.", + "settings.providers.apiKey.label": "API key", + "settings.providers.apiKey.placeholder": "Paste provider API key", + "settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.", + "settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.", + "settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.", + "settings.providers.oauth.openPage": "Open authorization page", + "settings.providers.oauth.openHere": "Open in this tab", + "settings.providers.oauth.copyLink": "Copy link", + "settings.providers.oauth.linkCopied": "Link copied", + "settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.", + "settings.providers.oauth.popup.loadingTitle": "OpenCode authorization", + "settings.providers.oauth.popup.loadingBody": "Preparing authorization page...", + "settings.providers.oauth.codeLabel": "Authorization code", + "settings.providers.oauth.codePlaceholder": "Paste code from provider", + "settings.providers.oauth.submitCode": "Submit code", + "settings.providers.oauth.waitingTitle": "Waiting for authorization", + "settings.providers.oauth.cancelWait": "Cancel", + "settings.providers.prompt.selectPlaceholder": "Select an option", + "settings.providers.success": "Provider authentication saved. Provider data has been refreshed.", + "settings.providers.source.api": "Saved provider authentication", + "settings.providers.source.config": "Configured in opencode.jsonc", + "settings.providers.source.custom": "Configured by custom provider logic", + "settings.providers.source.env": "Configured from environment variables", + "settings.providers.source.unknown": "Configured provider source", + "settings.providers.errors.loadFailed": "Failed to load provider authentication methods.", + "settings.providers.errors.noAuthorization": "Provider did not return authorization data.", + "settings.providers.errors.authorizationFailed": "Authorization failed.", + "settings.providers.errors.removeFailed": "Failed to remove provider authentication.", + "settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.", + "settings.appearance.behavior.title": "Взаимодействие", "settings.appearance.behavior.subtitle": "Значения по умолчанию для сообщений, диффов и ввода.", "settings.behavior.keyboardHints.title": "Подсказки сочетаний клавиш", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts index 613a5ca5..da6b8453 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/settings.ts @@ -32,6 +32,7 @@ export const settingsMessages = { "modelSelector.favoritesOnly.showAll": "显示所有模型", "modelSelector.favorite.add": "添加到收藏", "modelSelector.favorite.remove": "从收藏移除", + "modelSelector.manageProviders": "Manage Providers", "thinkingSelector.variant.default": "默认", "thinkingSelector.label": "思考:{variant}", @@ -70,6 +71,7 @@ export const settingsMessages = { "settings.nav.notifications": "Notifications", "settings.nav.remote": "Remote Access", "settings.nav.speech": "Speech", + "settings.nav.providers": "Providers", "settings.nav.opencode": "OpenCode", "settings.nav.info": "Info", "settings.scope.device": "This device", @@ -127,6 +129,63 @@ export const settingsMessages = { "settings.opencode.logLevel.option.warn": "警告", "settings.opencode.logLevel.option.error": "错误", + "settings.providers.title": "Provider Authentication", + "settings.providers.subtitle": "Connect API keys or OAuth accounts for the active OpenCode instance.", + "settings.providers.refresh": "Refresh", + "settings.providers.selectProvider": "Select provider", + "settings.providers.available.title": "Available Providers", + "settings.providers.configured.title": "Configured Providers", + "settings.providers.loading": "Loading provider authentication methods...", + "settings.providers.empty.noInstance": "Start an OpenCode workspace before managing provider authentication.", + "settings.providers.empty.noProviders": "No providers are available from this OpenCode instance.", + "settings.providers.empty.noConfiguredProviders": "No providers are currently configured for this OpenCode instance.", + "settings.providers.models.one": "{count} model", + "settings.providers.models.other": "{count} models", + "settings.providers.actions.connect": "Connect", + "settings.providers.actions.disconnect": "Disconnect", + "settings.providers.actions.remove": "Remove saved auth", + "settings.providers.actions.close": "Close", + "settings.providers.actions.continue": "Continue", + "settings.providers.actions.working": "Working...", + "settings.providers.method.title": "Authentication method", + "settings.providers.method.subtitle": "Choose the flow OpenCode exposes for this provider.", + "settings.providers.method.api": "API key", + "settings.providers.method.oauth": "OAuth", + "settings.providers.method.mixed": "API key and OAuth", + "settings.providers.method.fallback": "API key", + "settings.providers.status.available": "Available", + "settings.providers.auth.title": "Connect {provider}", + "settings.providers.auth.subtitle": "Authentication is saved inside the active OpenCode server.", + "settings.providers.apiKey.label": "API key", + "settings.providers.apiKey.placeholder": "Paste provider API key", + "settings.providers.oauth.noPrompts": "This OAuth method does not need additional details before opening the browser.", + "settings.providers.oauth.promptsUnsupported": "This OAuth method requires provider-specific prompt inputs that are not supported by the current SDK build.", + "settings.providers.oauth.enterCode": "Complete the browser flow, then paste the authorization code.", + "settings.providers.oauth.openPage": "Open authorization page", + "settings.providers.oauth.openHere": "Open in this tab", + "settings.providers.oauth.copyLink": "Copy link", + "settings.providers.oauth.linkCopied": "Link copied", + "settings.providers.oauth.popupBlocked": "Your browser may have blocked the authorization page. Use the actions above to continue the sign-in flow.", + "settings.providers.oauth.popup.loadingTitle": "OpenCode authorization", + "settings.providers.oauth.popup.loadingBody": "Preparing authorization page...", + "settings.providers.oauth.codeLabel": "Authorization code", + "settings.providers.oauth.codePlaceholder": "Paste code from provider", + "settings.providers.oauth.submitCode": "Submit code", + "settings.providers.oauth.waitingTitle": "Waiting for authorization", + "settings.providers.oauth.cancelWait": "Cancel", + "settings.providers.prompt.selectPlaceholder": "Select an option", + "settings.providers.success": "Provider authentication saved. Provider data has been refreshed.", + "settings.providers.source.api": "Saved provider authentication", + "settings.providers.source.config": "Configured in opencode.jsonc", + "settings.providers.source.custom": "Configured by custom provider logic", + "settings.providers.source.env": "Configured from environment variables", + "settings.providers.source.unknown": "Configured provider source", + "settings.providers.errors.loadFailed": "Failed to load provider authentication methods.", + "settings.providers.errors.noAuthorization": "Provider did not return authorization data.", + "settings.providers.errors.authorizationFailed": "Authorization failed.", + "settings.providers.errors.removeFailed": "Failed to remove provider authentication.", + "settings.providers.errors.envDisconnectUnavailable": "This provider is configured from environment variables and cannot be disconnected from the UI.", + "settings.appearance.behavior.title": "交互", "settings.appearance.behavior.subtitle": "消息、差异与输入的默认值。", "settings.behavior.keyboardHints.title": "键盘快捷键提示", diff --git a/packages/ui/src/lib/provider-auth.ts b/packages/ui/src/lib/provider-auth.ts new file mode 100644 index 00000000..9223cd37 --- /dev/null +++ b/packages/ui/src/lib/provider-auth.ts @@ -0,0 +1,58 @@ +export type ProviderAuthMethod = { + type: "oauth" | "api" + label: string + prompts?: ProviderAuthPrompt[] +} + +export type ProviderAuthPrompt = + | { + type: "text" + key: string + message: string + placeholder?: string + when?: ProviderAuthPromptCondition + } + | { + type: "select" + key: string + message: string + options: Array<{ label: string; value: string; hint?: string }> + when?: ProviderAuthPromptCondition + } + +export type ProviderAuthPromptCondition = { + key: string + op: "eq" | "neq" + value: string +} + +export type ProviderAuthAuthorization = { + url: string + method: "auto" | "code" + instructions: string +} + +export const genericApiMethod: ProviderAuthMethod = { type: "api", label: "" } + +export function extractProviderAuthErrorMessage(error: unknown, fallback: string): string { + const candidate = error as { + data?: { message?: unknown } + message?: unknown + error?: { data?: { message?: unknown }; message?: unknown } + } + const nested = candidate?.error + const message = candidate?.data?.message ?? nested?.data?.message ?? candidate?.message ?? nested?.message + return typeof message === "string" && message.trim().length > 0 ? message : fallback +} + +export function shouldShowProviderAuthPrompt(prompt: ProviderAuthPrompt, values: Record): boolean { + if (!prompt.when) return true + const actual = values[prompt.when.key] + if (actual === undefined) return false + return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value +} + +export function isAbortError(error: unknown): boolean { + const candidate = error as { name?: unknown; message?: unknown } + return candidate?.name === "AbortError" || candidate?.message === "This operation was aborted" +} diff --git a/packages/ui/src/stores/settings-screen.ts b/packages/ui/src/stores/settings-screen.ts index bf12e43f..f5c2a6a2 100644 --- a/packages/ui/src/stores/settings-screen.ts +++ b/packages/ui/src/stores/settings-screen.ts @@ -1,6 +1,13 @@ import { createSignal } from "solid-js" -export type SettingsSectionId = "appearance" | "notifications" | "remote" | "speech" | "opencode" | "sidecars" | "info" +export type SettingsSectionId = + | "appearance" + | "notifications" + | "remote" + | "speech" + | "opencode" + | "sidecars" + | "info" const [settingsOpen, setSettingsOpen] = createSignal(false) const [activeSettingsSection, setActiveSettingsSection] = createSignal("appearance") diff --git a/packages/ui/src/styles/components/provider-auth.css b/packages/ui/src/styles/components/provider-auth.css new file mode 100644 index 00000000..1acc258c --- /dev/null +++ b/packages/ui/src/styles/components/provider-auth.css @@ -0,0 +1,387 @@ +.providers-manager-modal { + position: fixed; + left: 50%; + top: 50%; + z-index: 60; + transform: translate(-50%, -50%); + width: min(880px, calc(100vw - 2rem)); + max-height: min(860px, calc(100dvh - 2rem)); + display: flex; + flex-direction: column; + overflow: hidden; + border-radius: 0; +} + +.providers-manager-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + border-bottom: 1px solid var(--border-base); +} + +.providers-manager-title { + color: var(--text-primary); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + line-height: 1.25; +} + +.providers-manager-body { + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 0; + overflow-y: auto; + padding: 1rem; +} + +.providers-connect-bar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 0.75rem; + align-items: center; + padding: 0.875rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: color-mix(in oklab, var(--surface-base) 84%, var(--surface-secondary) 16%); +} + +.providers-connect-select { + width: 100%; +} + +.providers-connect-panel, +.providers-list-section { + padding: 1rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: color-mix(in oklab, var(--surface-base) 88%, var(--surface-secondary) 12%); +} + +.providers-panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent); +} + +.providers-overview-card, +.providers-auth-card { + position: relative; +} + +.providers-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0.875rem; +} + +.providers-card { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 13rem; + padding: 1rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: + linear-gradient(135deg, color-mix(in oklab, var(--surface-base) 84%, var(--accent-primary) 5%), var(--surface-base)); + transition: border-color 140ms ease, background-color 140ms ease, transform 140ms ease; +} + +.providers-card:hover, +.providers-card[data-active="true"] { + border-color: color-mix(in oklab, var(--accent-primary) 34%, var(--border-base)); + background: color-mix(in oklab, var(--accent-primary) 8%, var(--surface-base)); +} + +.providers-card[data-active="true"] { + transform: translateY(-1px); +} + +.providers-card-main { + display: flex; + gap: 0.875rem; + min-width: 0; +} + +.providers-card-mark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.35rem; + height: 2.35rem; + flex-shrink: 0; + border: 1px solid color-mix(in oklab, var(--accent-primary) 22%, var(--border-base)); + border-radius: 0; + background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface-base)); + color: var(--accent-primary); +} + +.providers-card-mark-icon, +.providers-button-icon, +.providers-input-icon, +.providers-instructions-icon, +.providers-success-icon { + width: 1rem; + height: 1rem; +} + +.providers-card-copy { + min-width: 0; +} + +.providers-card-title { + color: var(--text-primary); + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + line-height: 1.25; +} + +.providers-card-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + min-width: 0; +} + +.providers-connected-badge { + display: inline-flex; + align-items: center; + flex-shrink: 0; + padding: 0.2rem 0.4rem; + border: 1px solid color-mix(in oklab, var(--accent-primary) 28%, var(--border-base)); + border-radius: 0; + background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base)); + color: var(--accent-primary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); +} + +.providers-card-meta, +.providers-card-methods, +.providers-model-count, +.providers-select-hint { + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +.providers-card-source { + margin-top: 0.35rem; + color: var(--text-secondary); + font-size: var(--font-size-xs); +} + +.providers-card-meta { + margin-top: 0.2rem; + overflow-wrap: anywhere; +} + +.providers-card-methods { + margin-top: 0.75rem; + line-height: 1.4; +} + +.providers-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-top: 1.25rem; + padding-top: 0.875rem; + border-top: 1px solid color-mix(in oklab, var(--border-base) 70%, transparent); +} + +.providers-disconnect-button { + width: min(9rem, 50%); + min-width: 6.5rem; + justify-content: center; + padding-inline: 0.75rem; +} + +.providers-loading-row, +.providers-waiting-card, +.providers-success-card, +.providers-oauth-instructions { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: var(--surface-base); + color: var(--text-secondary); + font-size: var(--font-size-sm); +} + +.providers-oauth-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.providers-oauth-link { + text-decoration: none; +} + +.providers-waiting-card { + align-items: flex-start; +} + +.providers-wait-cancel { + margin-inline-start: auto; + flex-shrink: 0; + width: auto; +} + +.providers-success-card { + border-color: color-mix(in oklab, var(--accent-primary) 34%, var(--border-base)); + background: color-mix(in oklab, var(--accent-primary) 10%, var(--surface-base)); + color: var(--text-primary); +} + +.providers-success-icon { + color: var(--accent-primary); +} + +.providers-form-stack { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.providers-field { + display: flex; + flex-direction: column; + gap: 0.4rem; + min-width: 0; +} + +.providers-input-wrap { + position: relative; + display: flex; + align-items: center; +} + +.providers-input-icon { + position: absolute; + inset-inline-start: 0.75rem; + color: var(--text-muted); + pointer-events: none; +} + +.providers-input { + width: 100%; + min-width: 0; + padding: 0.65rem 0.75rem; + border: 1px solid var(--border-base); + border-radius: 0; + background: var(--surface-base); + color: var(--text-primary); + font-size: var(--font-size-sm); + outline: none; +} + +.providers-input-wrap .providers-input { + padding-inline-start: 2.25rem; +} + +.providers-input:focus { + border-color: color-mix(in oklab, var(--accent-primary) 48%, var(--border-base)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent-primary) 16%, transparent); +} + +.providers-method-row { + margin-bottom: 1rem; + padding-top: 0; + border-top: none; +} + +.providers-method-trigger, +.providers-prompt-trigger { + min-width: min(22rem, 100%); +} + +.providers-select-hint { + margin-inline-start: 0.5rem; +} + +.providers-actions-row { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1rem; +} + +.providers-spin-icon { + width: 1rem; + height: 1rem; + animation: providers-spin 900ms linear infinite; +} + +@keyframes providers-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + .providers-manager-modal { + width: 100vw; + max-height: 100dvh; + height: 100dvh; + } + + .providers-connect-bar, + .providers-panel-header { + grid-template-columns: 1fr; + flex-direction: column; + align-items: stretch; + } + + .providers-connect-bar .selector-button, + .providers-connect-bar .settings-pill-button { + width: 100%; + justify-content: center; + } + + .providers-grid { + grid-template-columns: 1fr; + } + + .providers-card-footer, + .providers-actions-row { + align-items: stretch; + flex-direction: column; + } + + .providers-oauth-actions { + flex-direction: column; + } + + .providers-waiting-card { + flex-direction: column; + } + + .providers-wait-cancel { + margin-inline-start: 0; + align-self: flex-start; + } + + .providers-card-footer .selector-button, + .providers-actions-row .selector-button, + .providers-method-trigger, + .providers-prompt-trigger { + width: 100%; + } + + .providers-disconnect-button { + min-width: 0; + } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 3d7cffd5..04d800a5 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -11,3 +11,4 @@ @import "./components/permission-notification.css"; @import "./components/settings-screen.css"; @import "./components/settings-info.css"; +@import "./components/provider-auth.css";