mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
tui: support code and auto OAuth flows with improved authorization UI
Users can now authenticate providers using both code-based flows (entering an authorization code) and automatic flows. The dialog now clearly displays the authorization URL, allows code entry with retry capability, and shows a waiting indicator during automatic authentication.
This commit is contained in:
parent
c0f2588926
commit
05498becb1
9 changed files with 219 additions and 34 deletions
|
|
@ -110,9 +110,9 @@ export function DialogModel() {
|
|||
title: provider.name,
|
||||
category: "Popular providers",
|
||||
value: provider.id,
|
||||
footer: {
|
||||
opencode: "Recommended",
|
||||
anthropic: "Claude Max or API key",
|
||||
description: {
|
||||
opencode: "(Recommended)",
|
||||
anthropic: "(Claude Max or API key)",
|
||||
}[provider.id],
|
||||
async onSelect() {
|
||||
const key = await DialogPrompt.show(dialog, "Enter API key")
|
||||
|
|
@ -131,6 +131,7 @@ export function DialogModel() {
|
|||
dialog.replace(() => <DialogModel />)
|
||||
},
|
||||
})),
|
||||
filter((x) => PROVIDER_PRIORITY[x.value] !== undefined),
|
||||
sortBy((x) => PROVIDER_PRIORITY[x.value] ?? 99),
|
||||
)
|
||||
: []),
|
||||
|
|
@ -142,7 +143,7 @@ export function DialogModel() {
|
|||
keybind={[
|
||||
{
|
||||
keybind: { ctrl: true, name: "a", meta: false, shift: false, leader: false },
|
||||
title: "Connect provider",
|
||||
title: connected() ? "Connect provider" : "More providers",
|
||||
onTrigger(option) {
|
||||
dialog.replace(() => <DialogProvider />)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { DialogSelect } from "@tui/ui/dialog-select"
|
|||
import { Dialog, useDialog } from "@tui/ui/dialog"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { DialogPrompt } from "../ui/dialog-prompt"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
|
||||
const PROVIDER_PRIORITY: Record<string, number> = {
|
||||
opencode: 0,
|
||||
|
|
@ -20,6 +22,7 @@ export function createDialogProviderOptions() {
|
|||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const options = createMemo(() => {
|
||||
return pipe(
|
||||
sync.data.provider_next.all,
|
||||
|
|
@ -69,10 +72,61 @@ export function createDialogProviderOptions() {
|
|||
method: index,
|
||||
},
|
||||
})
|
||||
if (result.data?.method === "code")
|
||||
await DialogPrompt.show(dialog, result.data.url + " " + result.data.instructions)
|
||||
if (result.data?.method === "auto")
|
||||
await DialogPrompt.show(dialog, result.data.url + " " + result.data.instructions)
|
||||
if (result.data?.method === "code") {
|
||||
while (true) {
|
||||
const url = result.data.url
|
||||
const code = await DialogPrompt.show(dialog, "Login with " + method.label, {
|
||||
placeholder: "Authorization code",
|
||||
description: () => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.textMuted}>Visit the url to collect your authorization code</text>
|
||||
<text fg={theme.primary}>{url}</text>
|
||||
</box>
|
||||
),
|
||||
})
|
||||
if (!code) break
|
||||
const { error } = await sdk.client.provider.oauth.callback({
|
||||
path: {
|
||||
id: provider.id,
|
||||
},
|
||||
body: {
|
||||
method: index,
|
||||
code,
|
||||
},
|
||||
})
|
||||
if (!error) {
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.data?.method === "auto") {
|
||||
const { instructions, url } = result.data
|
||||
dialog.replace(() => (
|
||||
<PendingDialog
|
||||
title={method.label}
|
||||
description={() => (
|
||||
<box gap={1}>
|
||||
<text fg={theme.primary}>{url}</text>
|
||||
<text fg={theme.textMuted}>{instructions}</text>
|
||||
</box>
|
||||
)}
|
||||
/>
|
||||
))
|
||||
await sdk.client.provider.oauth.callback({
|
||||
path: {
|
||||
id: provider.id,
|
||||
},
|
||||
body: {
|
||||
method: index,
|
||||
},
|
||||
})
|
||||
dialog.clear()
|
||||
await sdk.client.instance.dispose()
|
||||
await sync.bootstrap()
|
||||
}
|
||||
}
|
||||
},
|
||||
})),
|
||||
|
|
@ -87,3 +141,21 @@ export function DialogProvider() {
|
|||
|
||||
return <DialogSelect title="Connect a provider" options={options()} />
|
||||
}
|
||||
|
||||
interface PendingDialogProps {
|
||||
title: string
|
||||
description: () => JSX.Element
|
||||
}
|
||||
function PendingDialog(props: PendingDialogProps) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box gap={1}>{props.description}</box>
|
||||
<text fg={theme.textMuted}>Waiting for authorization...</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -600,11 +600,9 @@ export function Session() {
|
|||
}
|
||||
|
||||
// Prompt for optional filename
|
||||
const customFilename = await DialogPrompt.show(
|
||||
dialog,
|
||||
"Export filename",
|
||||
`session-${sessionData.id.slice(0, 8)}.md`,
|
||||
)
|
||||
const customFilename = await DialogPrompt.show(dialog, "Export filename", {
|
||||
value: `session-${sessionData.id.slice(0, 8)}.md`,
|
||||
})
|
||||
|
||||
// Cancel if user pressed escape
|
||||
if (customFilename === null) return
|
||||
|
|
@ -894,7 +892,7 @@ function UserMessage(props: {
|
|||
const { theme } = useTheme()
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const queued = createMemo(() => props.pending && props.message.id > props.pending)
|
||||
const color = createMemo(() => (queued() ? theme.accent : theme.text))
|
||||
const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
|
||||
|
||||
const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
|
||||
|
||||
|
|
@ -942,7 +940,7 @@ function UserMessage(props: {
|
|||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
<text fg={theme.textMuted}>
|
||||
{sync.data.config.username ?? "You"}{" "}
|
||||
<Show
|
||||
when={queued()}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { TextareaRenderable, TextAttributes } from "@opentui/core"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog, type DialogContext } from "./dialog"
|
||||
import { onMount } from "solid-js"
|
||||
import { onMount, type JSX } from "solid-js"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
|
||||
export type DialogPromptProps = {
|
||||
title: string
|
||||
description?: () => JSX.Element
|
||||
placeholder?: string
|
||||
value?: string
|
||||
onConfirm?: (value: string) => void
|
||||
onCancel?: () => void
|
||||
|
|
@ -37,35 +39,37 @@ export function DialogPrompt(props: DialogPromptProps) {
|
|||
<text attributes={TextAttributes.BOLD}>{props.title}</text>
|
||||
<text fg={theme.textMuted}>esc</text>
|
||||
</box>
|
||||
<box>
|
||||
<box gap={1}>
|
||||
{props.description}
|
||||
<textarea
|
||||
onSubmit={() => {
|
||||
props.onConfirm?.(textarea.plainText)
|
||||
dialog.clear()
|
||||
}}
|
||||
height={3}
|
||||
keyBindings={[{ name: "return", action: "submit" }]}
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
initialValue={props.value}
|
||||
placeholder="Enter text"
|
||||
placeholder={props.placeholder ?? "Enter text"}
|
||||
/>
|
||||
</box>
|
||||
<box paddingBottom={1}>
|
||||
<text fg={theme.textMuted}>Press enter to confirm, esc to cancel</text>
|
||||
<box paddingBottom={1} gap={1} flexDirection="row">
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>cancel</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogPrompt.show = (dialog: DialogContext, title: string, value?: string) => {
|
||||
DialogPrompt.show = (dialog: DialogContext, title: string, options?: Omit<DialogPromptProps, "title">) => {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
dialog.replace(
|
||||
() => (
|
||||
<DialogPrompt
|
||||
title={title}
|
||||
value={value}
|
||||
onConfirm={(value) => resolve(value)}
|
||||
onCancel={() => resolve(null)}
|
||||
/>
|
||||
<DialogPrompt title={title} {...options} onConfirm={(value) => resolve(value)} onCancel={() => resolve(null)} />
|
||||
),
|
||||
() => resolve(null),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -237,12 +237,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||
)}
|
||||
</For>
|
||||
</scrollbox>
|
||||
<box paddingRight={2} paddingLeft={3} flexDirection="row" paddingBottom={1} gap={1}>
|
||||
<box paddingRight={2} paddingLeft={4} flexDirection="row" paddingBottom={1} gap={1}>
|
||||
<For each={props.keybind ?? []}>
|
||||
{(item) => (
|
||||
<text>
|
||||
<span style={{ fg: theme.text, attributes: TextAttributes.BOLD }}>{Keybind.toString(item.keybind)}</span>
|
||||
<span style={{ fg: theme.textMuted }}> {item.title}</span>
|
||||
<span style={{ fg: theme.text }}>
|
||||
<b>{item.title}</b>{" "}
|
||||
</span>
|
||||
<span style={{ fg: theme.textMuted }}>{Keybind.toString(item.keybind)}</span>
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
|
|
|
|||
|
|
@ -73,21 +73,22 @@ export namespace ProviderAuth {
|
|||
const match = await state().then((s) => s.pending[input.providerID])
|
||||
if (!match) throw new OauthMissing({ providerID: input.providerID })
|
||||
let result
|
||||
|
||||
if (match.method === "code") {
|
||||
if (!input.code) throw new OauthCodeMissing({ providerID: input.providerID })
|
||||
result = await match.callback(input.code)
|
||||
}
|
||||
|
||||
if (match.method === "auto") {
|
||||
result = await match.callback()
|
||||
}
|
||||
if (!result) return
|
||||
if (result.type === "success") {
|
||||
|
||||
if (result?.type === "success") {
|
||||
if ("key" in result) {
|
||||
await Auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
})
|
||||
return
|
||||
}
|
||||
if ("refresh" in result) {
|
||||
await Auth.set(input.providerID, {
|
||||
|
|
@ -96,9 +97,11 @@ export namespace ProviderAuth {
|
|||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
})
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw new OauthCallbackFailed({})
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -127,4 +130,6 @@ export namespace ProviderAuth {
|
|||
providerID: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1276,6 +1276,47 @@ export namespace Server {
|
|||
return c.json(result)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/provider/:id/oauth/callback",
|
||||
describeRoute({
|
||||
description: "Handle OAuth callback for a provider",
|
||||
operationId: "provider.oauth.callback",
|
||||
responses: {
|
||||
200: {
|
||||
description: "OAuth callback processed successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"param",
|
||||
z.object({
|
||||
id: z.string().meta({ description: "Provider ID" }),
|
||||
}),
|
||||
),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
method: z.number().meta({ description: "Auth method index" }),
|
||||
code: z.string().optional().meta({ description: "OAuth authorization code" }),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const id = c.req.valid("param").id
|
||||
const { method, code } = c.req.valid("json")
|
||||
await ProviderAuth.callback({
|
||||
providerID: id,
|
||||
method,
|
||||
code,
|
||||
})
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/find",
|
||||
describeRoute({
|
||||
|
|
|
|||
|
|
@ -101,6 +101,9 @@ import type {
|
|||
ProviderOauthAuthorizeData,
|
||||
ProviderOauthAuthorizeResponses,
|
||||
ProviderOauthAuthorizeErrors,
|
||||
ProviderOauthCallbackData,
|
||||
ProviderOauthCallbackResponses,
|
||||
ProviderOauthCallbackErrors,
|
||||
FindTextData,
|
||||
FindTextResponses,
|
||||
FindFilesData,
|
||||
|
|
@ -593,6 +596,24 @@ class Oauth extends _HeyApiClient {
|
|||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback for a provider
|
||||
*/
|
||||
public callback<ThrowOnError extends boolean = false>(options: Options<ProviderOauthCallbackData, ThrowOnError>) {
|
||||
return (options.client ?? this._client).post<
|
||||
ProviderOauthCallbackResponses,
|
||||
ProviderOauthCallbackErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/provider/{id}/oauth/callback",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class Provider extends _HeyApiClient {
|
||||
|
|
|
|||
|
|
@ -2588,6 +2588,47 @@ export type ProviderOauthAuthorizeResponses = {
|
|||
|
||||
export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses]
|
||||
|
||||
export type ProviderOauthCallbackData = {
|
||||
body?: {
|
||||
/**
|
||||
* Auth method index
|
||||
*/
|
||||
method: number
|
||||
/**
|
||||
* OAuth authorization code
|
||||
*/
|
||||
code?: string
|
||||
}
|
||||
path: {
|
||||
/**
|
||||
* Provider ID
|
||||
*/
|
||||
id: string
|
||||
}
|
||||
query?: {
|
||||
directory?: string
|
||||
}
|
||||
url: "/provider/{id}/oauth/callback"
|
||||
}
|
||||
|
||||
export type ProviderOauthCallbackErrors = {
|
||||
/**
|
||||
* Bad request
|
||||
*/
|
||||
400: BadRequestError
|
||||
}
|
||||
|
||||
export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors]
|
||||
|
||||
export type ProviderOauthCallbackResponses = {
|
||||
/**
|
||||
* OAuth callback processed successfully
|
||||
*/
|
||||
200: boolean
|
||||
}
|
||||
|
||||
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
|
||||
|
||||
export type FindTextData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue