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:
Dax Raad 2025-11-20 22:02:26 -05:00
parent c0f2588926
commit 05498becb1
9 changed files with 219 additions and 34 deletions

View file

@ -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 />)
},

View file

@ -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>
)
}

View file

@ -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()}

View file

@ -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),
)

View file

@ -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>

View file

@ -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({}))
}

View file

@ -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({

View file

@ -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 {

View file

@ -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