mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 65c96f914b | |||
| d0f1768b74 |
@@ -1,4 +1,3 @@
|
|||||||
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -13,21 +12,9 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
|||||||
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import {
|
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
|
||||||
type Accessor,
|
import { createStore } from "solid-js/store"
|
||||||
type Component,
|
import { useQueryClient } from "@tanstack/solid-query"
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
createResource,
|
|
||||||
createUniqueId,
|
|
||||||
For,
|
|
||||||
Match,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
Show,
|
|
||||||
Switch,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore, produce } from "solid-js/store"
|
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
@@ -37,10 +24,10 @@ import { useSettings } from "@/context/settings"
|
|||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { pathKey } from "@/utils/path-key"
|
||||||
|
import { createProviderConnectionController } from "./provider-connection-controller"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
|
||||||
|
|
||||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
const reset = () => setStore("selected", undefined)
|
const reset = () => setStore("selected", undefined)
|
||||||
@@ -384,118 +371,91 @@ function ProviderConnection(props: {
|
|||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const newLayout = settings.general.newLayoutDesigns
|
const newLayout = settings.general.newLayoutDesigns
|
||||||
const providers = useProviders(() => props.directory?.())
|
const providers = useProviders(() => props.directory?.())
|
||||||
const directory = () => props.directory?.() ?? decode64(params.dir)
|
const directory = () => props.directory?.() ?? decode64(params.dir)
|
||||||
const location = () => {
|
|
||||||
const value = directory()
|
|
||||||
return value ? { directory: value } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const alive = { value: true }
|
|
||||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
alive.value = false
|
|
||||||
if (timer.current === undefined) return
|
|
||||||
clearTimeout(timer.current)
|
|
||||||
timer.current = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const provider = createMemo(
|
const provider = createMemo(
|
||||||
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
|
||||||
)
|
)
|
||||||
const fallback = createMemo<ConnectMethod[]>(() => [
|
const controller = createProviderConnectionController({
|
||||||
{
|
provider: props.provider,
|
||||||
type: "key" as const,
|
directory,
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
fallbackKeyLabel: () => language.t("provider.connect.method.apiKey"),
|
||||||
|
requestFailed: () => language.t("common.requestFailed"),
|
||||||
|
invalidCode: () => language.t("provider.connect.oauth.code.invalid"),
|
||||||
|
services: {
|
||||||
|
integration: {
|
||||||
|
load: (integrationID, value) =>
|
||||||
|
serverSDK()
|
||||||
|
.api.integration.get({
|
||||||
|
integrationID,
|
||||||
|
location: value ? { directory: value } : undefined,
|
||||||
|
})
|
||||||
|
.then((result) => result.data),
|
||||||
|
},
|
||||||
|
connection: {
|
||||||
|
key: (integrationID, value, key) =>
|
||||||
|
serverSDK().api.integration.connect.key({
|
||||||
|
integrationID,
|
||||||
|
location: value ? { directory: value } : undefined,
|
||||||
|
key,
|
||||||
|
}),
|
||||||
|
oauth: (integrationID, value, methodID, inputs) =>
|
||||||
|
serverSDK()
|
||||||
|
.api.integration.oauth.connect({
|
||||||
|
integrationID,
|
||||||
|
methodID,
|
||||||
|
inputs,
|
||||||
|
location: value ? { directory: value } : undefined,
|
||||||
|
})
|
||||||
|
.then((result) => result.data),
|
||||||
|
status: (integrationID, value, attemptID) =>
|
||||||
|
serverSDK()
|
||||||
|
.api.integration.oauth.status({
|
||||||
|
integrationID,
|
||||||
|
attemptID,
|
||||||
|
location: value ? { directory: value } : undefined,
|
||||||
|
})
|
||||||
|
.then((result) => result.data),
|
||||||
|
complete: (integrationID, value, attemptID, code) =>
|
||||||
|
serverSDK().api.integration.oauth.complete({
|
||||||
|
integrationID,
|
||||||
|
attemptID,
|
||||||
|
location: value ? { directory: value } : undefined,
|
||||||
|
code,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
refresh: () =>
|
||||||
|
queryClient.refetchQueries(serverSync().queryOptions.providers(directory() ? pathKey(directory()!) : null)),
|
||||||
|
},
|
||||||
|
completion: {
|
||||||
|
finish: () => {
|
||||||
|
dialog.close()
|
||||||
|
showToast({
|
||||||
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
||||||
|
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
|
||||||
const [integration] = createResource(
|
|
||||||
() => ({ provider: props.provider, directory: directory() }),
|
|
||||||
(input) =>
|
|
||||||
serverSDK()
|
|
||||||
.api.integration.get({
|
|
||||||
integrationID: input.provider,
|
|
||||||
location: input.directory ? { directory: input.directory } : undefined,
|
|
||||||
})
|
|
||||||
.then((result) => result.data),
|
|
||||||
)
|
|
||||||
const loading = createMemo(() => integration.loading)
|
|
||||||
const methods = createMemo<ConnectMethod[]>(() => {
|
|
||||||
const values = integration.latest?.methods.filter(
|
|
||||||
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
|
|
||||||
)
|
|
||||||
return values?.length ? values : fallback()
|
|
||||||
})
|
})
|
||||||
const [store, setStore] = createStore({
|
const loading = controller.data.loading
|
||||||
methodIndex: undefined as undefined | number,
|
const methods = controller.data.methods
|
||||||
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
|
const method = controller.data.method
|
||||||
promptInputs: undefined as undefined | Record<string, string>,
|
const methodIndex = controller.data.methodIndex
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
const authorization = controller.data.authorization
|
||||||
error: undefined as string | undefined,
|
const state = controller.auth.state
|
||||||
})
|
const error = controller.auth.error
|
||||||
|
const connectKey = controller.auth.connectKey
|
||||||
type Action =
|
const completeCode = controller.auth.completeCode
|
||||||
| { type: "method.select"; index: number }
|
|
||||||
| { type: "method.reset" }
|
|
||||||
| { type: "auth.prompt" }
|
|
||||||
| { type: "auth.inputs"; inputs: Record<string, string> }
|
|
||||||
| { type: "auth.pending" }
|
|
||||||
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
|
|
||||||
| { type: "auth.error"; error: string }
|
|
||||||
|
|
||||||
function dispatch(action: Action) {
|
|
||||||
setStore(
|
|
||||||
produce((draft) => {
|
|
||||||
if (action.type === "method.select") {
|
|
||||||
draft.methodIndex = action.index
|
|
||||||
draft.authorization = undefined
|
|
||||||
draft.promptInputs = undefined
|
|
||||||
draft.state = undefined
|
|
||||||
draft.error = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (action.type === "method.reset") {
|
|
||||||
draft.methodIndex = undefined
|
|
||||||
draft.authorization = undefined
|
|
||||||
draft.promptInputs = undefined
|
|
||||||
draft.state = undefined
|
|
||||||
draft.error = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (action.type === "auth.prompt") {
|
|
||||||
draft.state = "prompt"
|
|
||||||
draft.error = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (action.type === "auth.inputs") {
|
|
||||||
draft.promptInputs = action.inputs
|
|
||||||
draft.state = undefined
|
|
||||||
draft.error = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (action.type === "auth.pending") {
|
|
||||||
draft.state = "pending"
|
|
||||||
draft.error = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (action.type === "auth.complete") {
|
|
||||||
draft.state = "complete"
|
|
||||||
draft.authorization = action.authorization
|
|
||||||
draft.error = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.state = "error"
|
|
||||||
draft.error = action.error
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
|
|
||||||
|
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
@@ -513,56 +473,7 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(value: unknown, fallback: string): string {
|
const selectMethod = controller.auth.select
|
||||||
if (value && typeof value === "object" && "data" in value) {
|
|
||||||
const data = (value as { data?: { message?: unknown } }).data
|
|
||||||
if (typeof data?.message === "string" && data.message) return data.message
|
|
||||||
}
|
|
||||||
if (value && typeof value === "object" && "error" in value) {
|
|
||||||
const nested = formatError((value as { error?: unknown }).error, "")
|
|
||||||
if (nested) return nested
|
|
||||||
}
|
|
||||||
if (value && typeof value === "object" && "message" in value) {
|
|
||||||
const message = (value as { message?: unknown }).message
|
|
||||||
if (typeof message === "string" && message) return message
|
|
||||||
}
|
|
||||||
if (value instanceof Error && value.message) return value.message
|
|
||||||
if (typeof value === "string" && value) return value
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
async function selectMethod(index: number, inputs?: Record<string, string>) {
|
|
||||||
if (timer.current !== undefined) {
|
|
||||||
clearTimeout(timer.current)
|
|
||||||
timer.current = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const method = methods()[index]
|
|
||||||
dispatch({ type: "method.select", index })
|
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
|
||||||
if (method.prompts?.length && !inputs) {
|
|
||||||
dispatch({ type: "auth.prompt" })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dispatch({ type: "auth.pending" })
|
|
||||||
await serverSDK()
|
|
||||||
.api.integration.oauth.connect({
|
|
||||||
integrationID: props.provider,
|
|
||||||
methodID: method.id,
|
|
||||||
inputs: inputs ?? {},
|
|
||||||
location: location(),
|
|
||||||
})
|
|
||||||
.then((x) => {
|
|
||||||
if (!alive.value) return
|
|
||||||
dispatch({ type: "auth.complete", authorization: x.data })
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (!alive.value) return
|
|
||||||
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthPromptsView() {
|
function AuthPromptsView() {
|
||||||
const [formStore, setFormStore] = createStore({
|
const [formStore, setFormStore] = createStore({
|
||||||
@@ -583,7 +494,7 @@ function ProviderConnection(props: {
|
|||||||
const current = createMemo(() => {
|
const current = createMemo(() => {
|
||||||
const all = prompts()
|
const all = prompts()
|
||||||
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
||||||
if (index === -1) return
|
if (index === -1) return undefined
|
||||||
return {
|
return {
|
||||||
index,
|
index,
|
||||||
prompt: all[index],
|
prompt: all[index],
|
||||||
@@ -597,13 +508,14 @@ function ProviderConnection(props: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function next(index: number, value: Record<string, string>) {
|
async function next(index: number, value: Record<string, string>) {
|
||||||
if (store.methodIndex === undefined) return
|
const selected = methodIndex()
|
||||||
|
if (selected === undefined) return
|
||||||
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
||||||
if (next !== -1) {
|
if (next !== -1) {
|
||||||
setFormStore("index", next)
|
setFormStore("index", next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await selectMethod(store.methodIndex, value)
|
await selectMethod(selected, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
@@ -617,12 +529,12 @@ function ProviderConnection(props: {
|
|||||||
const item = () => current()
|
const item = () => current()
|
||||||
const text = createMemo(() => {
|
const text = createMemo(() => {
|
||||||
const prompt = item()?.prompt
|
const prompt = item()?.prompt
|
||||||
if (!prompt || prompt.type !== "text") return
|
if (!prompt || prompt.type !== "text") return undefined
|
||||||
return prompt
|
return prompt
|
||||||
})
|
})
|
||||||
const select = createMemo(() => {
|
const select = createMemo(() => {
|
||||||
const prompt = item()?.prompt
|
const prompt = item()?.prompt
|
||||||
if (!prompt || prompt.type !== "select") return
|
if (!prompt || prompt.type !== "select") return undefined
|
||||||
return prompt
|
return prompt
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -693,32 +605,9 @@ function ProviderConnection(props: {
|
|||||||
listRef?.onKeyDown(e)
|
listRef?.onKeyDown(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
let auto = false
|
|
||||||
createEffect(() => {
|
|
||||||
if (auto) return
|
|
||||||
if (loading()) return
|
|
||||||
if (methods().length === 1) {
|
|
||||||
auto = true
|
|
||||||
void selectMethod(0)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function complete() {
|
|
||||||
await serverSync()
|
|
||||||
.refreshProviders()
|
|
||||||
.catch(() => undefined)
|
|
||||||
dialog.close()
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
|
|
||||||
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (methods().length > 1 && store.methodIndex !== undefined) {
|
if (methods().length > 1 && methodIndex() !== undefined) {
|
||||||
dispatch({ type: "method.reset" })
|
controller.auth.reset()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
props.onBack()
|
props.onBack()
|
||||||
@@ -806,9 +695,9 @@ function ProviderConnection(props: {
|
|||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const form = e.currentTarget as HTMLFormElement
|
if (!(e.currentTarget instanceof HTMLFormElement)) return
|
||||||
const formData = new FormData(form)
|
const value = new FormData(e.currentTarget).get("apiKey")
|
||||||
const apiKey = formData.get("apiKey") as string
|
const apiKey = typeof value === "string" ? value : ""
|
||||||
|
|
||||||
if (!apiKey?.trim()) {
|
if (!apiKey?.trim()) {
|
||||||
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
setFormStore("error", language.t("provider.connect.apiKey.required"))
|
||||||
@@ -816,12 +705,7 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
await serverSDK().api.integration.connect.key({
|
await connectKey(apiKey)
|
||||||
integrationID: props.provider,
|
|
||||||
location: location(),
|
|
||||||
key: apiKey,
|
|
||||||
})
|
|
||||||
await complete()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newLayout())
|
if (newLayout())
|
||||||
@@ -936,9 +820,9 @@ function ProviderConnection(props: {
|
|||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const form = e.currentTarget as HTMLFormElement
|
if (!(e.currentTarget instanceof HTMLFormElement)) return
|
||||||
const formData = new FormData(form)
|
const value = new FormData(e.currentTarget).get("code")
|
||||||
const code = formData.get("code") as string
|
const code = typeof value === "string" ? value : ""
|
||||||
|
|
||||||
if (!code?.trim()) {
|
if (!code?.trim()) {
|
||||||
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
setFormStore("error", language.t("provider.connect.oauth.code.required"))
|
||||||
@@ -946,20 +830,7 @@ function ProviderConnection(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFormStore("error", undefined)
|
setFormStore("error", undefined)
|
||||||
const result = await serverSDK()
|
setFormStore("error", await completeCode(code))
|
||||||
.api.integration.oauth.complete({
|
|
||||||
integrationID: props.provider,
|
|
||||||
attemptID: store.authorization!.attemptID,
|
|
||||||
location: location(),
|
|
||||||
code,
|
|
||||||
})
|
|
||||||
.then(() => ({ ok: true as const }))
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
|
||||||
if (result.ok) {
|
|
||||||
await complete()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newLayout())
|
if (newLayout())
|
||||||
@@ -967,7 +838,7 @@ function ProviderConnection(props: {
|
|||||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||||
<div>
|
<div>
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
<Link href={store.authorization!.url} class="text-v2-text-text-base">
|
<Link href={authorization()!.url} class="text-v2-text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.link")}
|
{language.t("provider.connect.oauth.code.visit.link")}
|
||||||
</Link>
|
</Link>
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
@@ -1006,7 +877,7 @@ function ProviderConnection(props: {
|
|||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.code.visit.prefix")}
|
{language.t("provider.connect.oauth.code.visit.prefix")}
|
||||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
<Link href={authorization()!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
|
||||||
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
@@ -1032,52 +903,18 @@ function ProviderConnection(props: {
|
|||||||
|
|
||||||
function OAuthAutoView() {
|
function OAuthAutoView() {
|
||||||
const code = createMemo(() => {
|
const code = createMemo(() => {
|
||||||
const instructions = store.authorization?.instructions
|
const instructions = authorization()?.instructions
|
||||||
if (instructions?.includes(":")) {
|
if (instructions?.includes(":")) {
|
||||||
return instructions.split(":").pop()?.trim()
|
return instructions.split(":").pop()?.trim()
|
||||||
}
|
}
|
||||||
return instructions
|
return instructions
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const poll = async () => {
|
|
||||||
const authorization = store.authorization
|
|
||||||
if (!authorization || !alive.value) return
|
|
||||||
const result = await serverSDK()
|
|
||||||
.api.integration.oauth.status({
|
|
||||||
integrationID: props.provider,
|
|
||||||
attemptID: authorization.attemptID,
|
|
||||||
location: location(),
|
|
||||||
})
|
|
||||||
.then((value) => ({ ok: true as const, status: value.data }))
|
|
||||||
.catch((error) => ({ ok: false as const, error }))
|
|
||||||
if (!alive.value) return
|
|
||||||
if (!result.ok) {
|
|
||||||
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.status.status === "complete") {
|
|
||||||
await complete()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.status.status === "failed") {
|
|
||||||
dispatch({ type: "auth.error", error: result.status.message })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.status.status === "expired") {
|
|
||||||
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
timer.current = setTimeout(poll, 1_000)
|
|
||||||
}
|
|
||||||
void poll()
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
{language.t("provider.connect.oauth.auto.visit.prefix")}
|
||||||
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
<Link href={authorization()!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
|
||||||
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -1121,7 +958,7 @@ function ProviderConnection(props: {
|
|||||||
<div
|
<div
|
||||||
onKeyDown={handleKey}
|
onKeyDown={handleKey}
|
||||||
tabIndex={newLayout() ? undefined : 0}
|
tabIndex={newLayout() ? undefined : 0}
|
||||||
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
|
autofocus={!newLayout() && methodIndex() === undefined ? true : undefined}
|
||||||
>
|
>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={loading()}>
|
<Match when={loading()}>
|
||||||
@@ -1132,10 +969,10 @@ function ProviderConnection(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.methodIndex === undefined}>
|
<Match when={methodIndex() === undefined}>
|
||||||
<MethodSelection />
|
<MethodSelection />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "pending"}>
|
<Match when={state() === "pending"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
@@ -1143,14 +980,14 @@ function ProviderConnection(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "prompt"}>
|
<Match when={state() === "prompt"}>
|
||||||
<AuthPromptsView />
|
<AuthPromptsView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={state() === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
<span>{language.t("provider.connect.status.failed", { error: error() ?? "" })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
@@ -1159,10 +996,10 @@ function ProviderConnection(props: {
|
|||||||
</Match>
|
</Match>
|
||||||
<Match when={method()?.type === "oauth"}>
|
<Match when={method()?.type === "oauth"}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={store.authorization?.mode === "code"}>
|
<Match when={authorization()?.mode === "code"}>
|
||||||
<OAuthCodeView />
|
<OAuthCodeView />
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={store.authorization?.mode === "auto"}>
|
<Match when={authorization()?.mode === "auto"}>
|
||||||
<OAuthAutoView />
|
<OAuthAutoView />
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, test, vi } from "bun:test"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import {
|
||||||
|
createProviderConnectionWorkflowController,
|
||||||
|
type ProviderConnectMethod,
|
||||||
|
} from "./provider-connection-controller"
|
||||||
|
|
||||||
|
const authorization = {
|
||||||
|
attemptID: "attempt-1",
|
||||||
|
url: "https://example.com/auth",
|
||||||
|
instructions: "Code: ABCD",
|
||||||
|
mode: "auto" as const,
|
||||||
|
time: { created: 0, expires: 1 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function services(options: {
|
||||||
|
methods: readonly ProviderConnectMethod[]
|
||||||
|
status?: () => Promise<{ status: "pending" | "complete" }>
|
||||||
|
refresh?: () => void
|
||||||
|
finish?: () => void
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
connection: {
|
||||||
|
key: async () => undefined,
|
||||||
|
oauth: async () => authorization,
|
||||||
|
status: options.status ?? (async () => ({ status: "pending" as const })),
|
||||||
|
complete: async () => undefined,
|
||||||
|
},
|
||||||
|
provider: { refresh: async () => options.refresh?.() },
|
||||||
|
completion: { finish: options.finish ?? (() => undefined) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function create(options: Parameters<typeof services>[0]) {
|
||||||
|
return createRoot((dispose) => ({
|
||||||
|
dispose,
|
||||||
|
controller: createProviderConnectionWorkflowController({
|
||||||
|
provider: "example",
|
||||||
|
directory: () => "/project",
|
||||||
|
requestFailed: () => "Request failed",
|
||||||
|
invalidCode: () => "Invalid code",
|
||||||
|
loading: () => false,
|
||||||
|
methods: () => [...options.methods],
|
||||||
|
services: services(options),
|
||||||
|
pollInterval: 100,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("provider connection controller", () => {
|
||||||
|
test("moves prompted OAuth methods through prompt and authorization states", async () => {
|
||||||
|
const owned = create({
|
||||||
|
methods: [
|
||||||
|
{
|
||||||
|
id: "oauth",
|
||||||
|
type: "oauth",
|
||||||
|
label: "OAuth",
|
||||||
|
prompts: [{ type: "text", key: "account", message: "Account" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await owned.controller.auth.select(0)
|
||||||
|
expect(owned.controller.auth.state()).toBe("prompt")
|
||||||
|
|
||||||
|
await owned.controller.auth.select(0, { account: "team" })
|
||||||
|
expect(owned.controller.auth.state()).toBe("complete")
|
||||||
|
expect(owned.controller.data.authorization()).toEqual(authorization)
|
||||||
|
owned.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("polls auto OAuth independently and completes after provider refresh", async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const calls: string[] = []
|
||||||
|
const statuses = [{ status: "pending" as const }, { status: "complete" as const }]
|
||||||
|
const owned = create({
|
||||||
|
methods: [{ id: "oauth", type: "oauth", label: "OAuth" }],
|
||||||
|
status: async () => statuses.shift() ?? { status: "complete" as const },
|
||||||
|
refresh: () => calls.push("refresh"),
|
||||||
|
finish: () => calls.push("finish"),
|
||||||
|
})
|
||||||
|
|
||||||
|
await owned.controller.auth.select(0)
|
||||||
|
await settle()
|
||||||
|
expect(owned.controller.data.authorization()).toEqual(authorization)
|
||||||
|
expect(calls).toEqual([])
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100)
|
||||||
|
await settle()
|
||||||
|
expect(calls).toEqual(["refresh", "finish"])
|
||||||
|
owned.dispose()
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cancels an owned poll timer on reset and disposal", async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
try {
|
||||||
|
const status = vi.fn(async () => ({ status: "pending" as const }))
|
||||||
|
const owned = create({ methods: [{ id: "oauth", type: "oauth", label: "OAuth" }], status })
|
||||||
|
|
||||||
|
await owned.controller.auth.select(0)
|
||||||
|
expect(status).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
owned.controller.auth.reset()
|
||||||
|
vi.advanceTimersByTime(1_000)
|
||||||
|
await settle()
|
||||||
|
expect(status).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await owned.controller.auth.select(0)
|
||||||
|
expect(status).toHaveBeenCalledTimes(2)
|
||||||
|
owned.dispose()
|
||||||
|
vi.advanceTimersByTime(1_000)
|
||||||
|
await settle()
|
||||||
|
expect(status).toHaveBeenCalledTimes(2)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||||
|
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||||
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
|
||||||
|
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
|
||||||
|
type Authorization = IntegrationOauthConnectOutput["data"]
|
||||||
|
type OAuthStatus = { status: "pending" | "complete" | "expired" } | { status: "failed"; message: string }
|
||||||
|
|
||||||
|
type ProviderConnectionServices = {
|
||||||
|
integration: {
|
||||||
|
load: (provider: string, directory?: string) => Promise<{ methods: readonly IntegrationMethod[] } | null>
|
||||||
|
}
|
||||||
|
connection: {
|
||||||
|
key: (provider: string, directory: string | undefined, key: string) => Promise<unknown>
|
||||||
|
oauth: (
|
||||||
|
provider: string,
|
||||||
|
directory: string | undefined,
|
||||||
|
method: string,
|
||||||
|
inputs: Record<string, string>,
|
||||||
|
) => Promise<Authorization>
|
||||||
|
status: (provider: string, directory: string | undefined, attempt: string) => Promise<OAuthStatus>
|
||||||
|
complete: (provider: string, directory: string | undefined, attempt: string, code: string) => Promise<unknown>
|
||||||
|
}
|
||||||
|
provider: { refresh: () => Promise<unknown> }
|
||||||
|
completion: { finish: () => void }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProviderConnectionController(options: {
|
||||||
|
provider: string
|
||||||
|
directory: () => string | undefined
|
||||||
|
fallbackKeyLabel: () => string
|
||||||
|
requestFailed: () => string
|
||||||
|
invalidCode: () => string
|
||||||
|
services: ProviderConnectionServices
|
||||||
|
pollInterval?: number
|
||||||
|
}) {
|
||||||
|
const [integration] = createResource(
|
||||||
|
() => ({ provider: options.provider, directory: options.directory() }),
|
||||||
|
(input) => options.services.integration.load(input.provider, input.directory),
|
||||||
|
)
|
||||||
|
const methods = createMemo<ProviderConnectMethod[]>(() => {
|
||||||
|
const values = integration.latest?.methods.filter(
|
||||||
|
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
|
||||||
|
)
|
||||||
|
if (values?.length) return [...values]
|
||||||
|
return [{ type: "key", label: options.fallbackKeyLabel() }]
|
||||||
|
})
|
||||||
|
return createProviderConnectionWorkflowController({
|
||||||
|
...options,
|
||||||
|
loading: () => integration.loading,
|
||||||
|
methods,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProviderConnectionWorkflowController(options: {
|
||||||
|
provider: string
|
||||||
|
directory: () => string | undefined
|
||||||
|
requestFailed: () => string
|
||||||
|
invalidCode: () => string
|
||||||
|
loading: () => boolean
|
||||||
|
methods: () => ProviderConnectMethod[]
|
||||||
|
services: Pick<ProviderConnectionServices, "connection" | "provider" | "completion">
|
||||||
|
pollInterval?: number
|
||||||
|
}) {
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
methodIndex: undefined as number | undefined,
|
||||||
|
authorization: undefined as Authorization | undefined,
|
||||||
|
state: "pending" as "pending" | "complete" | "error" | "prompt" | undefined,
|
||||||
|
error: undefined as string | undefined,
|
||||||
|
})
|
||||||
|
const polling = {
|
||||||
|
generation: 0,
|
||||||
|
timer: undefined as ReturnType<typeof setTimeout> | undefined,
|
||||||
|
disposed: false,
|
||||||
|
}
|
||||||
|
const methods = options.methods
|
||||||
|
const method = createMemo(() => (store.methodIndex === undefined ? undefined : methods().at(store.methodIndex)))
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: "method.select"; index: number }
|
||||||
|
| { type: "method.reset" }
|
||||||
|
| { type: "auth.prompt" }
|
||||||
|
| { type: "auth.pending" }
|
||||||
|
| { type: "auth.complete"; authorization: Authorization }
|
||||||
|
| { type: "auth.error"; error: string }
|
||||||
|
|
||||||
|
const dispatch = (action: Action) => {
|
||||||
|
setStore(
|
||||||
|
produce((draft) => {
|
||||||
|
if (action.type === "method.select") {
|
||||||
|
draft.methodIndex = action.index
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "method.reset") {
|
||||||
|
draft.methodIndex = undefined
|
||||||
|
draft.authorization = undefined
|
||||||
|
draft.state = undefined
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.prompt") {
|
||||||
|
draft.state = "prompt"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.pending") {
|
||||||
|
draft.state = "pending"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action.type === "auth.complete") {
|
||||||
|
draft.state = "complete"
|
||||||
|
draft.authorization = action.authorization
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.state = "error"
|
||||||
|
draft.error = action.error
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelPolling = () => {
|
||||||
|
polling.generation++
|
||||||
|
if (polling.timer === undefined) return
|
||||||
|
clearTimeout(polling.timer)
|
||||||
|
polling.timer = undefined
|
||||||
|
}
|
||||||
|
const finish = async () => {
|
||||||
|
cancelPolling()
|
||||||
|
await options.services.provider.refresh().catch(() => undefined)
|
||||||
|
if (polling.disposed) return
|
||||||
|
options.services.completion.finish()
|
||||||
|
}
|
||||||
|
const poll = async (authorization: Authorization, generation: number) => {
|
||||||
|
const result = await options.services.connection
|
||||||
|
.status(options.provider, options.directory(), authorization.attemptID)
|
||||||
|
.then((status) => ({ ok: true as const, status }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
if (polling.disposed || generation !== polling.generation) return
|
||||||
|
if (!result.ok) {
|
||||||
|
dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status.status === "complete") {
|
||||||
|
await finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status.status === "failed") {
|
||||||
|
dispatch({ type: "auth.error", error: result.status.message })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.status.status === "expired") {
|
||||||
|
dispatch({ type: "auth.error", error: options.requestFailed() })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
|
||||||
|
}
|
||||||
|
const select = async (index: number, inputs?: Record<string, string>) => {
|
||||||
|
cancelPolling()
|
||||||
|
const generation = polling.generation
|
||||||
|
const selected = methods()[index]
|
||||||
|
dispatch({ type: "method.select", index })
|
||||||
|
if (selected.type !== "oauth") return
|
||||||
|
if (selected.prompts?.length && !inputs) {
|
||||||
|
dispatch({ type: "auth.prompt" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch({ type: "auth.pending" })
|
||||||
|
const result = await options.services.connection
|
||||||
|
.oauth(options.provider, options.directory(), selected.id, inputs ?? {})
|
||||||
|
.then((authorization) => ({ ok: true as const, authorization }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
if (polling.disposed || generation !== polling.generation) return
|
||||||
|
if (!result.ok) {
|
||||||
|
dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dispatch({ type: "auth.complete", authorization: result.authorization })
|
||||||
|
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
|
||||||
|
}
|
||||||
|
const reset = () => {
|
||||||
|
cancelPolling()
|
||||||
|
dispatch({ type: "method.reset" })
|
||||||
|
}
|
||||||
|
const connectKey = async (key: string) => {
|
||||||
|
await options.services.connection.key(options.provider, options.directory(), key)
|
||||||
|
await finish()
|
||||||
|
}
|
||||||
|
const completeCode = async (code: string) => {
|
||||||
|
const authorization = store.authorization
|
||||||
|
if (!authorization) return options.invalidCode()
|
||||||
|
const result = await options.services.connection
|
||||||
|
.complete(options.provider, options.directory(), authorization.attemptID, code)
|
||||||
|
.then(() => ({ ok: true as const }))
|
||||||
|
.catch((error) => ({ ok: false as const, error }))
|
||||||
|
if (!result.ok) return formatProviderConnectionError(result.error, options.invalidCode())
|
||||||
|
await finish()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let auto = false
|
||||||
|
createEffect(() => {
|
||||||
|
if (auto || options.loading() || methods().length !== 1) return
|
||||||
|
auto = true
|
||||||
|
void select(0)
|
||||||
|
})
|
||||||
|
onCleanup(() => {
|
||||||
|
polling.disposed = true
|
||||||
|
cancelPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
loading: options.loading,
|
||||||
|
methods,
|
||||||
|
method,
|
||||||
|
methodIndex: () => store.methodIndex,
|
||||||
|
authorization: () => store.authorization,
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
state: () => store.state,
|
||||||
|
error: () => store.error,
|
||||||
|
select,
|
||||||
|
reset,
|
||||||
|
connectKey,
|
||||||
|
completeCode,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProviderConnectionController = ReturnType<typeof createProviderConnectionController>
|
||||||
|
|
||||||
|
export function formatProviderConnectionError(value: unknown, fallback: string): string {
|
||||||
|
if (value && typeof value === "object" && "data" in value) {
|
||||||
|
const data = value.data
|
||||||
|
if (data && typeof data === "object" && "message" in data && typeof data.message === "string" && data.message)
|
||||||
|
return data.message
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object" && "error" in value) {
|
||||||
|
const nested = formatProviderConnectionError(value.error, "")
|
||||||
|
if (nested) return nested
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object" && "message" in value) {
|
||||||
|
const message = value.message
|
||||||
|
if (typeof message === "string" && message) return message
|
||||||
|
}
|
||||||
|
if (value instanceof Error && value.message) return value.message
|
||||||
|
if (typeof value === "string" && value) return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user