fix(app): accept a pasted redirect URL for provider sign-in (#63315)

Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
This commit is contained in:
Filip
2026-11-11 15:51:46 +01:00
committed by GitHub
Unverified
parent 7b65261fcd
commit 2ef38b2172
84 changed files with 547 additions and 144 deletions
@@ -397,6 +397,18 @@ export function createProviderDialogController(options: {
return
}
if (effect.type === "paste") {
void serverSDK.api.integration.oauth
.complete({ integrationID, attemptID: effect.signIn.authorization.attemptID, location, code: effect.value })
.then(
() => result({ type: "paste.accepted", attempt: effect.attempt }),
(error) =>
result({ type: "paste.rejected", attempt: effect.attempt, error: formatServerError(error, language.t) }),
)
return
}
if (effect.type === "sync") return void sync()
void refresh(effect, integrationID, result)
+22 -3
View File
@@ -7,6 +7,7 @@ import { Checkbox } from "@opencode/ui/checkbox"
import { Loader } from "@opencode/ui/loader"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
import { RedirectPasteForm } from "@opencode/ui/redirect-paste"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, createMemo, createUniqueId, For, type JSX, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
@@ -443,11 +444,10 @@ function ProviderSteps(props: { provider: string; controller: ProviderDialogCont
const userCode = new URL(authorization.url).searchParams.get("user_code")
if (userCode) return userCode
// Device sign-ins put the code after a colon ("Enter code: ABCD-1234"); other instructions are not a code.
const instructions = authorization.instructions
if (instructions?.includes(":")) return instructions.split(":").pop()?.trim()
return instructions
})
// Asks one visible field at a time, like the TUI, so `when` conditions see every earlier answer.
@@ -913,7 +913,14 @@ function ProviderSteps(props: { provider: string; controller: ProviderDialogCont
function OAuthAutoView() {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>{language.t("provider.connect.oauth.auto.description", { provider: name() })}</div>
<div>
{language.t(
connect().paste
? "provider.connect.oauth.auto.paste.description"
: "provider.connect.oauth.auto.description",
{ provider: name() },
)}
</div>
<StatusRow>{language.t("provider.connect.status.waiting")}</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button
@@ -936,6 +943,18 @@ function ProviderSteps(props: { provider: string; controller: ProviderDialogCont
/>
)}
</Show>
<Show when={connect().paste}>
{(paste) => (
<RedirectPasteForm
value={paste().value}
error={paste().error}
locked={paste().locked}
submitting={paste().submitting}
onInput={(value) => props.controller.input({ type: "paste.changed", value })}
onSubmit={() => props.controller.input({ type: "paste.submitted" })}
/>
)}
</Show>
</div>
)
}
@@ -292,6 +292,129 @@ test("an unreachable OAuth status resumes the same attempt, and Back from it can
])
})
// The browser can't reach a remote server's callback, so the user pastes the URL it landed on instead.
function waitingWithPaste() {
const starting = replay([loaded([oauth]), { type: "method.selected", index: 0 }])
const start = starting.effects.find((effect) => effect.type === "start")
if (start?.type !== "start") throw new Error("expected a start effect")
return {
attempt: start.attempt,
state: transitionProviderConnect(starting.state, {
type: "oauth.started",
attempt: start.attempt,
authorization: { ...authorization("auto"), paste: true },
}).state,
}
}
test("a browser sign-in offers a paste box only when its attempt takes a pasted redirect", () => {
expect(selectProviderConnectView(waitingWithPaste().state).paste).toEqual({
value: "",
error: undefined,
locked: false,
submitting: false,
})
const plain = replay([
loaded([oauth]),
{ type: "method.selected", index: 0 },
{ type: "oauth.started", attempt: 1, authorization: authorization("auto") },
])
expect(selectProviderConnectView(plain.state).paste).toBeUndefined()
expect(transitionProviderConnect(plain.state, { type: "paste.changed", value: "http://x" }).state).toBe(plain.state)
})
test("a rejected paste shows the server error and keeps polling the same attempt", () => {
const waiting = waitingWithPaste()
const empty = transitionProviderConnect(waiting.state, { type: "paste.submitted" })
expect(empty.effects).toEqual([])
expect(selectProviderConnectView(empty.state).paste?.error).toEqual({ type: "required" })
const typed = transitionProviderConnect(empty.state, {
type: "paste.changed",
value: " http://127.0.0.1:1/callback?code=c&state=s ",
})
expect(selectProviderConnectView(typed.state).paste?.error).toBeUndefined()
const sent = transitionProviderConnect(typed.state, { type: "paste.submitted" })
expect(sent.effects).toMatchObject([
{ type: "paste", attempt: waiting.attempt, value: "http://127.0.0.1:1/callback?code=c&state=s" },
])
// The field takes no edits or second submit while the first is in flight.
expect(selectProviderConnectView(sent.state).paste).toMatchObject({ locked: true, submitting: true })
expect(transitionProviderConnect(sent.state, { type: "paste.submitted" }).effects).toEqual([])
expect(transitionProviderConnect(sent.state, { type: "paste.changed", value: "again" }).state).toBe(sent.state)
const rejected = transitionProviderConnect(sent.state, {
type: "paste.rejected",
attempt: waiting.attempt,
error: "This URL belongs to a different sign-in",
})
expect(selectProviderConnectView(rejected.state)).toMatchObject({
screen: "browser",
paste: {
value: " http://127.0.0.1:1/callback?code=c&state=s ",
error: { type: "rejected", message: "This URL belongs to a different sign-in" },
locked: false,
},
})
// Polling still decides how the sign-in ends.
const done = transitionProviderConnect(rejected.state, {
type: "polled",
attempt: waiting.attempt,
outcome: { status: "complete" },
})
expect(done.state.status).toBe("refreshing")
})
test("an accepted paste stays locked while polling reports how the sign-in ends", () => {
const waiting = waitingWithPaste()
const typed = transitionProviderConnect(waiting.state, { type: "paste.changed", value: "http://x" })
const sent = transitionProviderConnect(typed.state, { type: "paste.submitted" })
const accepted = transitionProviderConnect(sent.state, { type: "paste.accepted", attempt: waiting.attempt })
expect(selectProviderConnectView(accepted.state).paste).toEqual({
value: "http://x",
error: undefined,
locked: true,
submitting: false,
})
expect(transitionProviderConnect(accepted.state, { type: "paste.submitted" }).effects).toEqual([])
})
test("a paste result after Back is ignored, and a resumed sign-in starts editable", () => {
const waiting = waitingWithPaste()
const typed = transitionProviderConnect(waiting.state, { type: "paste.changed", value: "http://x" })
const sent = transitionProviderConnect(typed.state, { type: "paste.submitted" })
const back = transitionProviderConnect(sent.state, { type: "back" })
expect(transitionProviderConnect(back.state, { type: "paste.accepted", attempt: waiting.attempt }).state).toBe(
back.state,
)
const failed = transitionProviderConnect(sent.state, {
type: "polled",
attempt: waiting.attempt,
outcome: { status: "unreachable", error: "offline" },
})
const resumed = transitionProviderConnect(failed.state, { type: "retry" })
expect(selectProviderConnectView(resumed.state).paste).toMatchObject({ value: "http://x", locked: false })
expect(
transitionProviderConnect(resumed.state, { type: "paste.rejected", attempt: waiting.attempt, error: "late" }).state,
).toBe(resumed.state)
})
test("a failed refresh retries the refresh, not the sign-in", () => {
const sent = submittingKey()
const stored = transitionProviderConnect(sent.state, { type: "submit.succeeded", attempt: sent.attempt })
+85 -2
View File
@@ -33,6 +33,8 @@ export type ProviderSignIn =
readonly method: Extract<ProviderConnectMethod, { type: "oauth" }>
readonly authorization: ProviderAuthorization
readonly browserFailed: boolean
/** Set when the attempt also takes the redirect URL pasted from a browser that cannot reach the server. */
readonly paste?: ProviderPaste
}
| {
readonly kind: "code"
@@ -55,6 +57,13 @@ export type ProviderPolledSignIn = Extract<ProviderSignIn, { kind: "browser" | "
export type ProviderEntryError = { readonly type: "required" } | { readonly type: "rejected"; readonly message: string }
/** The pasted redirect URL of a browser sign-in. The browser's own callback still completes the sign-in meanwhile. */
export type ProviderPaste =
| { readonly status: "editing"; readonly value: string; readonly error?: ProviderEntryError }
| { readonly status: "submitting"; readonly value: string }
/** The server took the URL and finishes the sign-in; polling reports how it ends. */
| { readonly status: "accepted"; readonly value: string }
export type ProviderRetry =
| { readonly type: "start" }
| { readonly type: "poll"; readonly signIn: Extract<ProviderSignIn, { kind: "browser" }> }
@@ -110,6 +119,11 @@ export type ProviderConnectEvent =
readonly opened: boolean
}
| { readonly type: "entry.submitted"; readonly value: string }
| { readonly type: "paste.changed"; readonly value: string }
| { readonly type: "paste.submitted" }
/** The server took the pasted URL; the sign-in still settles through polling. */
| { readonly type: "paste.accepted"; readonly attempt: number }
| { readonly type: "paste.rejected"; readonly attempt: number; readonly error: string }
| { readonly type: "oauth.started"; readonly attempt: number; readonly authorization: ProviderAuthorization }
| { readonly type: "command.started"; readonly attempt: number; readonly command: ProviderCommandAttempt }
| { readonly type: "external.saved"; readonly attempt: number }
@@ -152,6 +166,12 @@ export type ProviderConnectEffect =
readonly value: string
readonly answer?: FormAnswer
}
| {
readonly type: "paste"
readonly attempt: number
readonly signIn: Extract<ProviderSignIn, { kind: "browser" }>
readonly value: string
}
| { readonly type: "refresh"; readonly attempt: number; readonly method: ProviderConnectMethod }
/** Reloads the catalogs without reporting back, for a credential saved after the dialog moved on. */
| { readonly type: "sync" }
@@ -333,6 +353,7 @@ export function selectProviderConnectView(state: ProviderConnectState) {
commandOutput: signIn?.kind === "command" ? signIn.output : "",
form: state.status === "form" ? selectProviderFormView(state.form) : undefined,
entryError: state.status === "entering" ? state.error : undefined,
paste: state.status === "waiting" && signIn?.kind === "browser" && signIn.paste ? pasteView(signIn.paste) : undefined,
// A typed secret is sent and the catalogs are refreshing: the form takes no second submit.
submitting: state.status === "submitting" || state.status === "refreshing",
error: state.status === "failed" ? state.error : undefined,
@@ -452,6 +473,14 @@ function polled(
state: Extract<ProviderConnectState, { status: "waiting" }>,
event: ProviderConnectEvent,
): ProviderConnectTransition {
if (
event.type === "paste.changed" ||
event.type === "paste.submitted" ||
event.type === "paste.accepted" ||
event.type === "paste.rejected"
)
return pasted(state, event)
if (event.type !== "polled") return unchanged(state)
const outcome = event.outcome
@@ -473,15 +502,69 @@ function polled(
return unchanged({ ...selection(state, state.attempt), status: "failed", error: outcome.error, retry })
}
// A paste only hands the server the URL: polling keeps running and reports how the sign-in ends.
function pasted(
state: Extract<ProviderConnectState, { status: "waiting" }>,
event: Extract<
ProviderConnectEvent,
{ type: "paste.changed" | "paste.submitted" | "paste.accepted" | "paste.rejected" }
>,
): ProviderConnectTransition {
const signIn = state.signIn
if (signIn.kind !== "browser" || !signIn.paste) return unchanged(state)
const paste = signIn.paste
const set = (next: ProviderPaste) => ({ ...state, signIn: { ...signIn, paste: next } })
if (event.type === "paste.changed") {
if (paste.status !== "editing" || (event.value === paste.value && !paste.error)) return unchanged(state)
return unchanged(set({ status: "editing", value: event.value }))
}
if (event.type === "paste.submitted") {
if (paste.status !== "editing") return unchanged(state)
const value = paste.value.trim()
if (!value) return unchanged(set({ status: "editing", value: paste.value, error: { type: "required" } }))
return {
state: set({ status: "submitting", value: paste.value }),
effects: [{ type: "paste", attempt: state.attempt, signIn, value }],
}
}
if (paste.status !== "submitting") return unchanged(state)
if (event.type === "paste.accepted") return unchanged(set({ status: "accepted", value: paste.value }))
return unchanged(set({ status: "editing", value: paste.value, error: { type: "rejected", message: event.error } }))
}
function pasteView(paste: ProviderPaste) {
return {
value: paste.value,
error: paste.status === "editing" ? paste.error : undefined,
locked: paste.status !== "editing",
submitting: paste.status === "submitting",
}
}
function poll(
state: Extract<ProviderConnectState, { status: "starting" | "failed" }>,
signIn: ProviderPolledSignIn,
): ProviderConnectTransition {
const attempt = state.status === "starting" ? state.attempt : state.attempt + 1
// A paste in flight when polling failed reports to the old attempt, so a resumed sign-in starts editable too.
const next: ProviderPolledSignIn =
signIn.kind === "browser" && signIn.authorization.paste
? { ...signIn, paste: { status: "editing", value: signIn.paste?.value ?? "" } }
: signIn
return {
state: { ...selection(state, attempt), status: "waiting", signIn },
effects: [{ type: "poll", attempt, signIn, delay: false }],
state: { ...selection(state, attempt), status: "waiting", signIn: next },
effects: [{ type: "poll", attempt, signIn: next, delay: false }],
}
}
@@ -1,9 +1,9 @@
import { createUniqueId, onMount, Show } from "solid-js"
import { Show } from "solid-js"
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Loader } from "@opencode/ui/loader"
import { createMcpSignIn, type McpSignInFailure } from "@opencode/ui/mcp-sign-in"
import { TextInput } from "@opencode/ui/text-input"
import { RedirectPasteForm } from "@opencode/ui/redirect-paste"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
@@ -28,7 +28,6 @@ export function DialogMcpSignIn(props: {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
const errorID = createUniqueId()
const attempt = { integrationID: props.integrationID, attemptID: props.attemptID, location: props.location }
const signIn = createMcpSignIn({
@@ -45,7 +44,6 @@ export function DialogMcpSignIn(props: {
const view = signIn.view
const dispatch = signIn.dispatch
let input: HTMLInputElement | undefined
const failure = (value: McpSignInFailure) => {
if (value.type === "auth-expired")
@@ -59,16 +57,6 @@ export function DialogMcpSignIn(props: {
return language.t("mcp.timedOut", { name: props.name })
}
const entryError = () => {
const error = view().entryError
if (!error) return
return error.type === "rejected" ? error.message : language.t("dialog.mcp.signIn.paste.required")
}
onMount(() => input?.focus({ preventScroll: true }))
return (
<Dialog size="normal">
<DialogHeader>
@@ -107,40 +95,15 @@ export function DialogMcpSignIn(props: {
</div>
</Show>
<Show when={view().showForm}>
<form
onSubmit={(event) => {
event.preventDefault()
dispatch({ type: "paste.submitted" })
}}
class="flex flex-col items-start gap-5 self-stretch"
>
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{language.t("dialog.mcp.signIn.paste.label")}
<TextInput
ref={input}
class="!w-full"
name="redirect"
placeholder={language.t("dialog.mcp.signIn.paste.placeholder")}
value={view().value}
readOnly={view().locked}
invalid={entryError() !== undefined}
aria-describedby={entryError() ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => dispatch({ type: "paste.changed", value: event.currentTarget.value })}
/>
</label>
<Show when={entryError()}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<Button type="submit" variant="contrast" disabled={view().locked} aria-busy={view().submitting}>
{language.t("common.continue")}
</Button>
</form>
<RedirectPasteForm
value={view().value}
error={view().entryError}
locked={view().locked}
submitting={view().submitting}
autofocus
onInput={(value) => dispatch({ type: "paste.changed", value })}
onSubmit={() => dispatch({ type: "paste.submitted" })}
/>
</Show>
</div>
</DialogBody>
+2 -3
View File
@@ -272,6 +272,8 @@ export const dict = {
"Check that your browser shows the same code before you authorize.",
"provider.connect.oauth.auto.description":
"Your browser opens so you can sign in to {{provider}}. Come back here when you are done.",
"provider.connect.oauth.auto.paste.description":
"Your browser opens so you can sign in to {{provider}}. If it can't reach the machine running OpenCode, paste the URL it was redirected to below.",
"provider.connect.oauth.code.description":
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
"provider.connect.oauth.openBrowser": "Open browser",
@@ -390,9 +392,6 @@ export const dict = {
"dialog.mcp.signIn.open": "Sign in to {{name}}",
"dialog.mcp.signIn.description":
"Your browser opens so you can sign in to {{name}}. If it can't reach the machine running OpenCode, paste the URL it was redirected to below.",
"dialog.mcp.signIn.paste.label": "Redirect URL",
"dialog.mcp.signIn.paste.placeholder": "http://127.0.0.1:…/callback?code=…",
"dialog.mcp.signIn.paste.required": "Paste the URL from the browser's address bar",
"dialog.mcp.signIn.syncFailed": "Signed in, but couldn't refresh {{name}}: {{error}}",
"dialog.mcp.error.details":
"MCP server: {{name}}\nStatus: failed\nConfiguration: mcp.servers.{{name}}\nError: {{error}}",
+6 -17
View File
@@ -28,6 +28,7 @@ import type { AddressInfo } from "node:net"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { Credential } from "../credential.js"
import { OauthCallbackPage } from "../oauth/page.js"
import { OauthRedirect } from "../oauth/redirect.js"
import type { Integration } from "../integration.js"
import { ErrorSummary } from "../util/error-summary.js"
@@ -624,23 +625,11 @@ export const authorize = (input: {
callback: Deferred.await(completed),
// A pasted URL is held to the same checks as the browser's redirect, but a wrong paste keeps the login waiting.
submit: (pasted: string) =>
Effect.gen(function* () {
const text = pasted.trim()
if (!URL.canParse(text))
return yield* Effect.fail(new Error("Paste the full URL from the browser's address bar"))
const url = new URL(text)
if (url.searchParams.get("state") !== state)
return yield* Effect.fail(new Error("This URL belongs to a different sign-in"))
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
if (error) {
yield* Deferred.fail(code, new Error(error))
return
}
const value = url.searchParams.get("code")
if (!value) return yield* Effect.fail(new Error("The URL does not contain an authorization code"))
yield* Effect.logInfo("mcp oauth redirect pasted", fields)
yield* Deferred.succeed(code, { code: value, iss: url.searchParams.get("iss") ?? undefined })
}),
OauthRedirect.submit({
state,
callback: code,
received: (value, params) => ({ code: value, iss: params.get("iss") ?? undefined }),
})(pasted).pipe(Effect.tap(() => Effect.logInfo("mcp oauth redirect pasted", fields))),
}
}).pipe(
Effect.onError((cause) =>
+42
View File
@@ -0,0 +1,42 @@
import { Deferred, Effect } from "effect"
/**
* Builds an automatic sign-in's `submit`: reads the redirect URL the user pasted with `pasted`, then settles the
* sign-in's pending `callback` as the browser's redirect would. `received` turns the redirect into that value.
*/
export const submit =
<A>(input: {
state: string
callback: Deferred.Deferred<A, Error>
received: (code: string, params: URLSearchParams) => A
}) =>
(text: string) =>
pasted(text, input.state).pipe(
Effect.flatMap((result) =>
"error" in result
? Deferred.fail(input.callback, new Error(result.error))
: Deferred.succeed(input.callback, input.received(result.code, result.params)),
),
Effect.asVoid,
)
/**
* Reads the redirect URL a user pasted when the browser could not reach this machine's callback server
* (SSH, a remote desktop, a container). It must carry the sign-in's `state`, the same check the browser's
* redirect passes. A failure here rejects only this paste, so the sign-in keeps waiting; an `error` the
* provider put in a valid redirect is returned, and ends the sign-in as it would in the browser.
*/
export const pasted = (text: string, state: string) =>
Effect.gen(function* () {
const value = text.trim()
if (!URL.canParse(value)) return yield* Effect.fail(new Error("Paste the full URL from the browser's address bar"))
const params = new URL(value).searchParams
if (params.get("state") !== state) return yield* Effect.fail(new Error("This URL belongs to a different sign-in"))
const error = params.get("error_description") ?? params.get("error")
if (error) return { params, error }
const code = params.get("code")
if (!code) return yield* Effect.fail(new Error("The URL does not contain an authorization code"))
return { params, code }
})
export * as OauthRedirect from "./redirect.js"
+10 -3
View File
@@ -11,6 +11,7 @@ import { Integration } from "../../integration.js"
// import { IntegrationConnection } from "../../integration/connection.js"
import { Model } from "../../model.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { OauthRedirect } from "../../oauth/redirect.js"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
@@ -110,7 +111,8 @@ const signIn = (app: App.Info, savedClientID: () => string | undefined, storage:
const state = randomValue()
const nonce = randomValue()
const savedID = savedClientID()
const received = yield* Deferred.make<{ code: string; clientID?: string; response: ServerResponse }, Error>()
// `response` is the browser's pending callback request; a pasted redirect has none.
const received = yield* Deferred.make<{ code: string; clientID?: string; response?: ServerResponse }, Error>()
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
@@ -159,7 +161,7 @@ const signIn = (app: App.Info, savedClientID: () => string | undefined, storage:
const respond = (error?: string) =>
Effect.sync(() =>
result.response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
?.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: "ChatGPT" })
@@ -188,9 +190,14 @@ const signIn = (app: App.Info, savedClientID: () => string | undefined, storage:
}).pipe(
Effect.tap(() => respond()),
Effect.tapError((error) => respond(error instanceof Error ? error.message : "ChatGPT sign-in failed")),
Effect.onInterrupt(() => Effect.sync(() => result.response.destroy())),
Effect.onInterrupt(() => Effect.sync(() => result.response?.destroy())),
)
}),
submit: OauthRedirect.submit({
state,
callback: received,
received: (code, params) => ({ code, clientID: params.get("client_id") ?? undefined }),
}),
}
}),
refresh: (value) => refresh(value, app),
+6 -3
View File
@@ -11,6 +11,7 @@ import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Model } from "../../model.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { OauthRedirect } from "../../oauth/redirect.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
import { credentialSettings, metadataSettings } from "./credential-settings.js"
@@ -189,7 +190,8 @@ export const GitLabPlugin = define({
).toString("base64url")
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>()
// `response` is the browser's pending callback request; a pasted redirect has none.
const callback = yield* Deferred.make<{ code: string; response?: ServerResponse }, Error>()
const { createServer } = yield* Effect.promise(() => import("node:http"))
const server = createServer((request, response) => {
const url = new URL(request.url ?? "/", `http://${callbackHost}`)
@@ -233,7 +235,7 @@ export const GitLabPlugin = define({
const respond = (error?: string) =>
Effect.sync(() =>
request.response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
?.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: "GitLab" })
@@ -256,9 +258,10 @@ export const GitLabPlugin = define({
Effect.tap(() => respond()),
Effect.tapError((error) => respond(error.message)),
// Bun's server.closeAllConnections() leaves an unanswered callback response pending.
Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())),
Effect.onInterrupt(() => Effect.sync(() => request.response?.destroy())),
)
}),
submit: OauthRedirect.submit({ state, callback, received: (code) => ({ code }) }),
}
}),
})
@@ -7,6 +7,7 @@ import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { OauthRedirect } from "../../oauth/redirect.js"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
@@ -106,6 +107,7 @@ const browser = (app: App.Info) =>
Effect.flatMap((value) => exchange(value, redirect, pkce, app)),
Effect.map((tokens) => credential(browserMethodID, tokens)),
),
submit: OauthRedirect.submit({ state, callback: code, received: (value) => value }),
}
}),
refresh: (value) => refresh(browserMethodID, value, app),
+16 -3
View File
@@ -5,6 +5,7 @@ import type { ServerResponse } from "node:http"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { OauthRedirect } from "../../oauth/redirect.js"
const integrationID = Integration.ID.make("poe")
const methodID = Integration.MethodID.make("browser")
@@ -45,7 +46,8 @@ export const PoePlugin = define({
yield* Effect.promise(() => crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
).toString("base64url")
const state = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const callback = yield* Deferred.make<{ code: string; response: ServerResponse }, Error>()
// `response` is the browser's pending callback request; a pasted redirect has none.
const callback = yield* Deferred.make<{ code: string; response?: ServerResponse }, Error>()
const { createServer } = yield* Effect.promise(() => import("node:http"))
const { EventEmitter } = yield* Effect.promise(() => import("node:events"))
const server = createServer((request, response) => {
@@ -93,7 +95,7 @@ export const PoePlugin = define({
const respond = (error?: string) =>
Effect.sync(() =>
request.response
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
?.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
.end(
error
? OauthCallbackPage.error(error, { provider: "Poe" })
@@ -104,9 +106,20 @@ export const PoePlugin = define({
Effect.tap(() => respond()),
Effect.tapError((error) => respond(error.message)),
// Bun's server.closeAllConnections() leaves an unanswered callback response pending.
Effect.onInterrupt(() => Effect.sync(() => request.response.destroy())),
Effect.onInterrupt(() => Effect.sync(() => request.response?.destroy())),
)
}),
// Not `OauthRedirect.submit`: a pasted redirect also has to pass Poe's issuer check.
submit: (pasted: string) =>
OauthRedirect.pasted(pasted, state).pipe(
Effect.flatMap((result) => {
const error = callbackError(result.params, state)
return error
? Deferred.fail(callback, new Error(error))
: Deferred.succeed(callback, { code: result.params.get("code") ?? "" })
}),
Effect.asVoid,
),
}
}),
})
@@ -6,6 +6,7 @@ import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { OauthRedirect } from "../../oauth/redirect.js"
import { Provider } from "../../provider.js"
import { configuredSettings } from "./configured.js"
import { ProviderRefresh } from "./refresh.js"
@@ -164,6 +165,7 @@ export const SnowflakeCortexPlugin = define({
code_verifier: verifier,
})
}),
submit: OauthRedirect.submit({ state, callback: code, received: (value) => value }),
}
}),
})
@@ -171,6 +171,34 @@ for (const expiry of [3600, null, undefined]) {
)
}
// The browser runs on another machine, so its redirect never reaches this server's callback listener.
it.live("completes a login from a pasted redirect URL and keeps waiting after a wrong paste", () =>
Effect.gen(function* () {
const test = yield* fixture
const login = yield* test.connect
const complete = (code: string) =>
test.integrations.oauth.complete({ integrationID, attemptID: login.attempt.attemptID, code })
const foreign = new URL(login.callback)
foreign.searchParams.set("state", "another-login")
expect(yield* complete(foreign.href).pipe(Effect.flip)).toMatchObject({
cause: { message: "This URL belongs to a different sign-in" },
})
expect((yield* test.integrations.oauth.status({ integrationID, attemptID: login.attempt.attemptID })).status).toBe(
"pending",
)
test.replies.push(Response.json({ api_key: "poe-key", api_key_expires_in: 3600 }))
yield* complete(login.callback.href)
expect((yield* test.status(login.attempt.attemptID)).status).toBe("complete")
expect(Object.fromEntries(new URLSearchParams(yield* Effect.promise(() => test.requests[0].text())))).toMatchObject(
{
code: "auth-code",
},
)
}),
)
it.live("isolates overlapping login attempts and closes cancelled listeners", () =>
Effect.gen(function* () {
const test = yield* fixture
@@ -60,9 +60,6 @@ export default {
"mcp.signIn.failed": "Authorization failed: {{error}}",
"mcp.signIn.expired": "Authorization expired",
"mcp.signIn.openBrowser": "Open browser",
"mcp.signIn.paste.label": "Redirect URL",
"mcp.signIn.paste.placeholder": "http://127.0.0.1:…/callback?code=…",
"mcp.signIn.paste.required": "Paste the URL from the browser's address bar",
"mcp.signIn.syncFailed": "Signed in, but couldn't refresh {{name}}: {{error}}",
"mcp.error.open": "{{name}} failed, view error",
"mcp.error.title": "MCP server: {{name}}",
@@ -6,8 +6,8 @@ import { Icon } from "@opencode/ui/icon"
import { Loader } from "@opencode/ui/loader"
import { createMcpSignIn, type McpSignInFailure } from "@opencode/ui/mcp-sign-in"
import { MenuScroll } from "@opencode/ui/menu-scroll"
import { RedirectPasteForm } from "@opencode/ui/redirect-paste"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@opencode/ui/toast"
import { Tooltip } from "@opencode/ui/tooltip"
import { TruncatedText } from "@opencode/ui/truncated-text"
@@ -19,7 +19,6 @@ import {
For,
Index,
onCleanup,
onMount,
Show,
untrack,
type Accessor,
@@ -1544,7 +1543,6 @@ function McpSignInDialog(props: {
onClose: () => void
}) {
const ctx = usePlugin()
const errorID = createUniqueId()
const attempt = () => ({
integrationID: props.integrationID,
@@ -1568,7 +1566,6 @@ function McpSignInDialog(props: {
const view = signIn.view
const dispatch = signIn.dispatch
let input: HTMLInputElement | undefined
const failure = (value: McpSignInFailure) => {
if (value.type === "auth-expired") return ctx.t("mcp.signIn.failed", { error: ctx.t("mcp.signIn.expired") })
@@ -1580,16 +1577,6 @@ function McpSignInDialog(props: {
return ctx.t("mcp.timedOut", { name: props.name })
}
const entryError = () => {
const error = view().entryError
if (!error) return
return error.type === "rejected" ? error.message : ctx.t("mcp.signIn.paste.required")
}
onMount(() => input?.focus({ preventScroll: true }))
return (
<Dialog>
<DialogHeader>
@@ -1628,40 +1615,15 @@ function McpSignInDialog(props: {
</div>
</Show>
<Show when={view().showForm}>
<form
onSubmit={(event) => {
event.preventDefault()
dispatch({ type: "paste.submitted" })
}}
class="flex flex-col items-start gap-5 self-stretch"
>
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{ctx.t("mcp.signIn.paste.label")}
<TextInput
ref={input}
class="!w-full"
name="redirect"
placeholder={ctx.t("mcp.signIn.paste.placeholder")}
value={view().value}
readOnly={view().locked}
invalid={entryError() !== undefined}
aria-describedby={entryError() ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => dispatch({ type: "paste.changed", value: event.currentTarget.value })}
/>
</label>
<Show when={entryError()}>
{(error) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{error()}
</div>
)}
</Show>
<Button type="submit" variant="contrast" disabled={view().locked} aria-busy={view().submitting}>
{ctx.t("common.continue")}
</Button>
</form>
<RedirectPasteForm
value={view().value}
error={view().entryError}
locked={view().locked}
submitting={view().submitting}
autofocus
onInput={(value) => dispatch({ type: "paste.changed", value })}
onSubmit={() => dispatch({ type: "paste.submitted" })}
/>
</Show>
</div>
</DialogBody>
@@ -55,6 +55,11 @@ export type IntegrationOAuthAuthorization = {
| {
readonly mode: "auto"
readonly callback: Effect.Effect<Credential.OAuth, unknown>
/**
* Accepts the redirect URL a user pasted when the browser could not reach this machine, and settles `callback`
* with it. Check the URL's `state` as the browser path does; a failure rejects only this paste.
*/
readonly submit?: (redirect: string) => Effect.Effect<void, unknown>
}
| {
readonly mode: "code"
+17 -11
View File
@@ -441,17 +441,23 @@ export function fromPromise(plugin: Plugin) {
...input,
authorize: (answer) =>
Effect.promise(() => input.authorize(answer)).pipe(
Effect.map((authorization) =>
authorization.mode === "auto"
? {
...authorization,
callback: Effect.promise(() => authorization.callback),
}
: {
...authorization,
callback: (code) => Effect.promise(() => authorization.callback(code)),
},
),
Effect.map((authorization) => {
if (authorization.mode === "code")
return {
...authorization,
callback: (code: string) => Effect.promise(() => authorization.callback(code)),
}
const submit = authorization.submit
return {
...authorization,
callback: Effect.promise(() => authorization.callback),
// A rejected paste keeps its own error, which the dialog shows as is.
submit: submit
? (redirect: string) =>
Effect.tryPromise({ try: () => submit(redirect), catch: (error) => error })
: undefined,
}
}),
),
refresh:
refresh === undefined
@@ -54,6 +54,11 @@ export type IntegrationOAuthAuthorization = {
| {
readonly mode: "auto"
readonly callback: Promise<Credential.OAuth>
/**
* Accepts the redirect URL a user pasted when the browser could not reach this machine, and settles `callback`
* with it. Check the URL's `state` as the browser path does; a rejection rejects only this paste.
*/
readonly submit?: (redirect: string) => Promise<void>
}
| {
readonly mode: "code"
+1
View File
@@ -67,6 +67,7 @@
"./progress-circle": "./src/feedback/progress-circle/progress-circle.tsx",
"./project-avatar": "./src/data-display/project-avatar/project-avatar.tsx",
"./radio": "./src/forms/radio/radio.tsx",
"./redirect-paste": "./src/forms/redirect-paste/redirect-paste.tsx",
"./search-list": "./src/forms/search-list/search-list.tsx",
"./segmented-control": "./src/navigation/segmented-control/segmented-control.tsx",
"./select": "./src/forms/select/select.tsx",
@@ -0,0 +1,75 @@
import { createUniqueId, onMount, Show } from "solid-js"
import { Button } from "@opencode/ui/button"
import { TextInput } from "@opencode/ui/text-input"
import { useI18n } from "../../context/i18n"
/** Why the last paste was not taken: the field was empty, or the server rejected the URL with `message`. */
export type RedirectPasteError = { readonly type: "required" } | { readonly type: "rejected"; readonly message: string }
/**
* The field of a browser sign-in that takes the URL the browser was redirected to, for when the browser cannot reach
* the machine running the server (SSH, a remote desktop connection, a container).
*/
export function RedirectPasteForm(props: {
value: string
error: RedirectPasteError | undefined
/** Takes no edits or submits: a paste is in flight, or the server took one and is finishing the sign-in. */
locked: boolean
/** A paste is in flight. */
submitting: boolean
/** Focuses the field on mount, for a dialog where pasting is the main action. */
autofocus?: boolean
onInput: (value: string) => void
onSubmit: () => void
}) {
const i18n = useI18n()
const errorID = createUniqueId()
let input: HTMLInputElement | undefined
const error = () => {
if (!props.error) return
return props.error.type === "rejected" ? props.error.message : i18n.t("ui.redirectPaste.required")
}
onMount(() => {
if (props.autofocus) input?.focus({ preventScroll: true })
})
return (
<form
onSubmit={(event) => {
event.preventDefault()
props.onSubmit()
}}
class="flex flex-col items-start gap-5 self-stretch"
>
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
{i18n.t("ui.redirectPaste.label")}
<TextInput
ref={input}
class="!w-full"
name="redirect"
placeholder={i18n.t("ui.redirectPaste.placeholder")}
value={props.value}
readOnly={props.locked}
invalid={error() !== undefined}
aria-describedby={error() ? errorID : undefined}
autocomplete="off"
spellcheck={false}
onInput={(event) => props.onInput(event.currentTarget.value)}
/>
</label>
<Show when={error()}>
{(message) => (
<div id={errorID} role="alert" class="-mt-4 text-xs text-v2-state-fg-danger">
{message()}
</div>
)}
</Show>
<Button type="submit" variant="contrast" disabled={props.locked} aria-busy={props.submitting}>
{i18n.t("ui.common.continue")}
</Button>
</form>
)
}
+1
View File
@@ -159,6 +159,7 @@ export const dict = {
"ui.common.dismiss": "አሰናብት",
"ui.common.close": "ዝጋ",
"ui.common.next": "ቀጣይ",
"ui.common.continue": "ቀጥል",
"ui.common.submit": "አስረክብ",
"ui.common.showMore": "ተጨማሪ አሳይ",
"ui.permission.deny": "ክድ",
+1
View File
@@ -182,6 +182,7 @@ export const dict = {
"ui.common.dismiss": "إهمال",
"ui.common.close": "إغلاق",
"ui.common.next": "التالي",
"ui.common.continue": "متابعة",
"ui.common.submit": "إرسال",
"ui.common.showMore": "عرض المزيد",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "Bağla",
"ui.common.close": "Bağla",
"ui.common.next": "Növbəti",
"ui.common.continue": "Davam et",
"ui.common.submit": "Göndər",
"ui.common.showMore": "Daha çox göstər",
"ui.permission.deny": "Rədd et",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "Отхвърляне",
"ui.common.close": "затвори",
"ui.common.next": "Следваща",
"ui.common.continue": "Продължи",
"ui.common.submit": "Изпратете",
"ui.common.showMore": "Покажи повече",
"ui.permission.deny": "Откажи",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"ui.common.dismiss": "খারিজ",
"ui.common.close": "বন্ধ",
"ui.common.next": "পরবর্তী",
"ui.common.continue": "চালিয়ে যান",
"ui.common.submit": "জমা দিন",
"ui.common.showMore": "আরও দেখান",
"ui.permission.deny": "অস্বীকার করুন",
+1
View File
@@ -216,6 +216,7 @@ export const dict = {
"ui.common.dismiss": "Descartar",
"ui.common.close": "Fechar",
"ui.common.next": "Próximo",
"ui.common.continue": "Continuar",
"ui.common.submit": "Enviar",
"ui.common.showMore": "Mostrar mais",
+1
View File
@@ -174,6 +174,7 @@ export const dict = {
"ui.common.dismiss": "Odbaci",
"ui.common.close": "Zatvori",
"ui.common.next": "Dalje",
"ui.common.continue": "Nastavi",
"ui.common.submit": "Pošalji",
"ui.common.showMore": "Prikaži više",
+1
View File
@@ -168,6 +168,7 @@ export const dict = {
"ui.common.dismiss": "Descartar",
"ui.common.close": "Tancar",
"ui.common.next": "A continuació",
"ui.common.continue": "Continua",
"ui.common.submit": "Envia",
"ui.common.showMore": "Mostra'n més",
"ui.permission.deny": "Negar",
+1
View File
@@ -168,6 +168,7 @@ export const dict = {
"ui.common.dismiss": "Odmítnout",
"ui.common.close": "Zavřít",
"ui.common.next": "Další",
"ui.common.continue": "Pokračujte",
"ui.common.submit": "Odeslat",
"ui.common.showMore": "Zobrazit více",
"ui.permission.deny": "Odmítnout",
+1
View File
@@ -182,6 +182,7 @@ export const dict = {
"ui.common.dismiss": "Afvis",
"ui.common.close": "Luk",
"ui.common.next": "Næste",
"ui.common.continue": "Fortsæt",
"ui.common.submit": "Indsend",
"ui.common.showMore": "Vis mere",
+1
View File
@@ -189,6 +189,7 @@ export const dict = {
"ui.common.dismiss": "Verwerfen",
"ui.common.close": "Schließen",
"ui.common.next": "Weiter",
"ui.common.continue": "Weiter",
"ui.common.submit": "Absenden",
"ui.common.showMore": "Mehr anzeigen",
+1
View File
@@ -161,6 +161,7 @@ export const dict = {
"ui.common.dismiss": "ދުރުކޮށްލުން",
"ui.common.close": "ލެއްޕުން",
"ui.common.next": "ދެން",
"ui.common.continue": "ކުރިއަށްގެންދިޔުން",
"ui.common.submit": "ހުށަހެޅުން",
"ui.common.showMore": "އިތުރަށް ދައްކާ",
"ui.permission.deny": "ދޮގުކުރުން",
+1
View File
@@ -169,6 +169,7 @@ export const dict = {
"ui.common.dismiss": "ཁུངས༌མེད༌བརྩི༌ནི",
"ui.common.close": "སྒོ་བསྡམ",
"ui.common.next": "ཤུལ༌མའི",
"ui.common.continue": "འཕྲོ༌མཐུད",
"ui.common.submit": "སྤྲོད་ནི",
"ui.common.showMore": "མང་སུ་སྟོན།",
"ui.common.moreCount.one": "+{{count}} མངམ།",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"ui.common.dismiss": "Παράβλεψη",
"ui.common.close": "Κλείσιμο",
"ui.common.next": "Επόμενο",
"ui.common.continue": "Συνέχεια",
"ui.common.submit": "Υποβολή",
"ui.common.showMore": "Εμφάνιση περισσότερων",
"ui.permission.deny": "Απόρριψη",
+5
View File
@@ -240,6 +240,10 @@ const source = {
"ui.tabs.close": "Close tab",
"ui.redirectPaste.label": "Redirect URL",
"ui.redirectPaste.placeholder": "http://127.0.0.1:…/callback?code=…",
"ui.redirectPaste.required": "Paste the URL from the browser's address bar",
"ui.textField.copyToClipboard": "Copy to clipboard",
"ui.textField.copyLink": "Copy link",
"ui.textField.copied": "Copied",
@@ -312,6 +316,7 @@ const source = {
"ui.common.dismiss": "Dismiss",
"ui.common.close": "Close",
"ui.common.next": "Next",
"ui.common.continue": "Continue",
"ui.common.submit": "Submit",
"ui.common.showMore": "Show more",
"ui.common.showLess": "Show less",
+1
View File
@@ -215,6 +215,7 @@ export const dict = {
"ui.common.dismiss": "Descartar",
"ui.common.close": "Cerrar",
"ui.common.next": "Siguiente",
"ui.common.continue": "Continuar",
"ui.common.submit": "Enviar",
"ui.common.showMore": "Mostrar más",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"ui.common.dismiss": "Loobu",
"ui.common.close": "Sule",
"ui.common.next": "Edasi",
"ui.common.continue": "Jätka",
"ui.common.submit": "Esita",
"ui.common.showMore": "Näita rohkem",
"ui.permission.deny": "Keela",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "رد کردن",
"ui.common.close": "بستن",
"ui.common.next": "بعدی",
"ui.common.continue": "ادامه دهید",
"ui.common.submit": "ارسال کنید",
"ui.common.showMore": "نمایش بیشتر",
"ui.permission.deny": "تکذیب کنید",
+1
View File
@@ -179,6 +179,7 @@ export const dict = {
"ui.common.dismiss": "Hylkää",
"ui.common.close": "Sulje",
"ui.common.next": "Seuraava",
"ui.common.continue": "Jatka",
"ui.common.submit": "Lähetä",
"ui.common.showMore": "Näytä lisää",
"ui.permission.deny": "Kiellä",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"ui.common.dismiss": "Lat aftur",
"ui.common.close": "Lat aftur",
"ui.common.next": "Næsta",
"ui.common.continue": "Halt fram",
"ui.common.submit": "Send inn",
"ui.common.showMore": "Vís meira",
"ui.permission.deny": "Neita",
+1
View File
@@ -217,6 +217,7 @@ export const dict = {
"ui.common.dismiss": "Ignorer",
"ui.common.close": "Fermer",
"ui.common.next": "Suivant",
"ui.common.continue": "Continuer",
"ui.common.submit": "Soumettre",
"ui.common.showMore": "Afficher plus",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "סגור",
"ui.common.close": "סגור",
"ui.common.next": "הבא",
"ui.common.continue": "המשך",
"ui.common.submit": "שלח",
"ui.common.showMore": "הצג עוד",
"ui.permission.deny": "דחה",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"ui.common.dismiss": "हटाएँ",
"ui.common.close": "बंद करें",
"ui.common.next": "अगला",
"ui.common.continue": "जारी रखें",
"ui.common.submit": "जमा करें",
"ui.common.showMore": "और दिखाएँ",
"ui.permission.deny": "अस्वीकार करें",
+1
View File
@@ -165,6 +165,7 @@ export const dict = {
"ui.common.dismiss": "Odbaciti",
"ui.common.close": "Zatvoriti",
"ui.common.next": "Sljedeći",
"ui.common.continue": "Nastaviti",
"ui.common.submit": "Pošalji",
"ui.common.showMore": "Prikaži više",
"ui.permission.deny": "Odbij",
+1
View File
@@ -165,6 +165,7 @@ export const dict = {
"ui.common.dismiss": "Elvetés",
"ui.common.close": "Bezárás",
"ui.common.next": "Következő",
"ui.common.continue": "Folytatás",
"ui.common.submit": "Beküld",
"ui.common.showMore": "Mutasson többet",
"ui.permission.deny": "Megtagadás",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "Փակել",
"ui.common.close": "Փակել",
"ui.common.next": "Հաջորդ",
"ui.common.continue": "Շարունակել",
"ui.common.submit": "Ներկայացնել",
"ui.common.showMore": "Ցույց տալ ավելին",
"ui.permission.deny": "Մերժել",
+1
View File
@@ -183,6 +183,7 @@ export const dict = {
"ui.common.dismiss": "Tutup",
"ui.common.close": "Tutup",
"ui.common.next": "Berikutnya",
"ui.common.continue": "Lanjutkan",
"ui.common.submit": "Kirim",
"ui.common.showMore": "Tampilkan lebih banyak",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"ui.common.dismiss": "Loka",
"ui.common.close": "Loka",
"ui.common.next": "Næst",
"ui.common.continue": "Halda áfram",
"ui.common.submit": "Sendu inn",
"ui.common.showMore": "Sýna meira",
"ui.permission.deny": "Neita",
+1
View File
@@ -211,6 +211,7 @@ export const dict = {
"ui.common.dismiss": "Ignora",
"ui.common.close": "Chiudi",
"ui.common.next": "Avanti",
"ui.common.continue": "Continua",
"ui.common.submit": "Invia",
"ui.common.showMore": "Mostra di più",
"ui.permission.deny": "Nega",
+1
View File
@@ -170,6 +170,7 @@ export const dict = {
"ui.common.dismiss": "閉じる",
"ui.common.close": "閉じる",
"ui.common.next": "次へ",
"ui.common.continue": "続行",
"ui.common.submit": "送信",
"ui.common.showMore": "さらに表示",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "დატოვება",
"ui.common.close": "დახურვა",
"ui.common.next": "შემდეგი",
"ui.common.continue": "გაგრძელება",
"ui.common.submit": "გაგზავნა",
"ui.common.showMore": "აჩვენე მეტი",
"ui.permission.deny": "უარი",
+1
View File
@@ -166,6 +166,7 @@ export const dict = {
"ui.common.dismiss": "ច្រានចោល",
"ui.common.close": "បិទ",
"ui.common.next": "បន្ទាប់",
"ui.common.continue": "បន្ត",
"ui.common.submit": "ដាក់ស្នើ",
"ui.common.showMore": "មើលច្រើនទៀត",
"ui.permission.deny": "បដិសេធ",
+1
View File
@@ -147,6 +147,7 @@ export const dict = {
"ui.common.dismiss": "닫기",
"ui.common.close": "닫기",
"ui.common.next": "다음",
"ui.common.continue": "계속",
"ui.common.submit": "제출",
"ui.common.showMore": "더 보기",
+1
View File
@@ -165,6 +165,7 @@ export const dict = {
"ui.common.dismiss": "ປິດ",
"ui.common.close": "ປິດ",
"ui.common.next": "ຕໍ່ໄປ",
"ui.common.continue": "ສືບຕໍ່",
"ui.common.submit": "ສົ່ງ",
"ui.common.showMore": "ສະແດງເພີ່ມເຕີມ",
"ui.permission.deny": "ປະຕິເສດ",
+1
View File
@@ -173,6 +173,7 @@ export const dict = {
"ui.common.dismiss": "Atsisakyti",
"ui.common.close": "Uždaryti",
"ui.common.next": "Kitas",
"ui.common.continue": "Tęsti",
"ui.common.submit": "Pateikti",
"ui.common.showMore": "Rodyti daugiau",
"ui.permission.deny": "Atmesti",
+1
View File
@@ -168,6 +168,7 @@ export const dict = {
"ui.common.dismiss": "Aizvērt",
"ui.common.close": "Aizvērt",
"ui.common.next": "Tālāk",
"ui.common.continue": "Turpināt",
"ui.common.submit": "Iesniegt",
"ui.common.showMore": "Rādīt vairāk",
"ui.permission.deny": "Liegt",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "Отфрли",
"ui.common.close": "Затвори",
"ui.common.next": "Следно",
"ui.common.continue": "Продолжи",
"ui.common.submit": "Поднесете",
"ui.common.showMore": "Прикажи повеќе",
"ui.permission.deny": "Негирајте",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "Хаах",
"ui.common.close": "Хаах",
"ui.common.next": "Дараа нь",
"ui.common.continue": "Үргэлжлүүлэх",
"ui.common.submit": "Илгээх",
"ui.common.showMore": "Илүү ихийг харуулах",
"ui.permission.deny": "Үгүйсгэх",
+1
View File
@@ -165,6 +165,7 @@ export const dict = {
"ui.common.dismiss": "Abaikan",
"ui.common.close": "Tutup",
"ui.common.next": "Seterusnya",
"ui.common.continue": "Teruskan",
"ui.common.submit": "Hantar",
"ui.common.showMore": "Lihat lagi",
"ui.permission.deny": "Tolak",
+1
View File
@@ -166,6 +166,7 @@ export const dict = {
"ui.common.dismiss": "ပယ်ရန်",
"ui.common.close": "ပိတ်ပါ။",
"ui.common.next": "နောက်တစ်ခု",
"ui.common.continue": "ဆက်ရန်",
"ui.common.submit": "တင်ပြပါ။",
"ui.common.showMore": "နောက်ထပ်ပြပါ။",
"ui.permission.deny": "ငြင်းဆို",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"ui.common.dismiss": "खारेज गर्नुहोस्",
"ui.common.close": "बन्द गर्नुहोस्",
"ui.common.next": "अर्को",
"ui.common.continue": "जारी राख्नुहोस्",
"ui.common.submit": "पेश गर्नुहोस्",
"ui.common.showMore": "थप देखाउनुहोस्",
"ui.permission.deny": "अस्वीकार गर्नुहोस्",
+1
View File
@@ -197,6 +197,7 @@ export const dict = {
"ui.common.dismiss": "Negeren",
"ui.common.close": "Sluiten",
"ui.common.next": "Volgende",
"ui.common.continue": "Doorgaan",
"ui.common.submit": "Verzenden",
"ui.common.showMore": "Laat meer zien",
"ui.permission.deny": "Weigeren",
+1
View File
@@ -181,6 +181,7 @@ export const dict: Record<Keys, string> = {
"ui.common.dismiss": "Avvis",
"ui.common.close": "Lukk",
"ui.common.next": "Neste",
"ui.common.continue": "Fortsett",
"ui.common.submit": "Send inn",
"ui.common.showMore": "Vis mer",
"ui.common.clear": "Tøm",
+1
View File
@@ -161,6 +161,7 @@ export const dict = {
"ui.common.dismiss": "برخاست کرو",
"ui.common.close": "بند کرو",
"ui.common.next": "اگلا",
"ui.common.continue": "جاری رکھو",
"ui.common.submit": "جمع کراؤ",
"ui.common.showMore": "مزید وکھاؤ",
"ui.permission.deny": "منع کرو",
+1
View File
@@ -172,6 +172,7 @@ export const dict = {
"ui.common.dismiss": "Odrzuć",
"ui.common.close": "Zamknij",
"ui.common.next": "Dalej",
"ui.common.continue": "Kontynuuj",
"ui.common.submit": "Prześlij",
"ui.common.showMore": "Pokaż więcej",
"ui.common.clear": "Wyczyść",
+1
View File
@@ -168,6 +168,7 @@ export const dict = {
"ui.common.dismiss": "Închide",
"ui.common.close": "Închide",
"ui.common.next": "Următorul",
"ui.common.continue": "Continuă",
"ui.common.submit": "Trimite",
"ui.common.showMore": "Afișează mai mult",
"ui.permission.deny": "Refuză",
+1
View File
@@ -171,6 +171,7 @@ export const dict = {
"ui.common.dismiss": "Закрыть",
"ui.common.close": "Закрыть",
"ui.common.next": "Далее",
"ui.common.continue": "Продолжить",
"ui.common.submit": "Отправить",
"ui.common.showMore": "Показать ещё",
"ui.common.clear": "Очистить",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "අස් කරන්න",
"ui.common.close": "වසන්න",
"ui.common.next": "ඊළඟ",
"ui.common.continue": "දිගටම කරගෙන යන්න",
"ui.common.submit": "ඉදිරිපත් කරන්න",
"ui.common.showMore": "තව පෙන්වන්න",
"ui.permission.deny": "ප්‍රතික්ෂේප කරන්න",
+1
View File
@@ -188,6 +188,7 @@ export const dict = {
"ui.common.dismiss": "Zavrieť",
"ui.common.close": "Zatvoriť",
"ui.common.next": "Ďalej",
"ui.common.continue": "Pokračovať",
"ui.common.submit": "Odoslať",
"ui.common.showMore": "Zobraziť viac",
"ui.permission.deny": "Odmietnuť",
+1
View File
@@ -189,6 +189,7 @@ export const dict = {
"ui.common.dismiss": "Odpusti",
"ui.common.close": "Zapri",
"ui.common.next": "Naprej",
"ui.common.continue": "Nadaljuj",
"ui.common.submit": "Predloži",
"ui.common.showMore": "Pokaži več",
"ui.permission.deny": "Zavrni",
+1
View File
@@ -163,6 +163,7 @@ export const dict = {
"ui.common.dismiss": "Largoje",
"ui.common.close": "Mbylle",
"ui.common.next": "Tjetra",
"ui.common.continue": "Vazhdoni",
"ui.common.submit": "Paraqisni",
"ui.common.showMore": "Trego më shumë",
"ui.permission.deny": "Moho",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"ui.common.dismiss": "Одбаци",
"ui.common.close": "Затвори",
"ui.common.next": "Следеће",
"ui.common.continue": "Настави",
"ui.common.submit": "Пошаљи",
"ui.common.showMore": "Прикажи више",
"ui.permission.deny": "Дени",
+1
View File
@@ -197,6 +197,7 @@ export const dict = {
"ui.common.dismiss": "Avfärda",
"ui.common.close": "Stäng",
"ui.common.next": "Nästa",
"ui.common.continue": "Fortsätt",
"ui.common.submit": "Skicka",
"ui.common.showMore": "Visa mer",
"ui.permission.deny": "Neka",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "Рад кардан",
"ui.common.close": "Пӯшед",
"ui.common.next": "Баъдӣ",
"ui.common.continue": "Давом додан",
"ui.common.submit": "Пешниҳод кунед",
"ui.common.showMore": "Бештар нишон диҳед",
"ui.permission.deny": "Инкор кардан",
+1
View File
@@ -171,6 +171,7 @@ export const dict = {
"ui.common.dismiss": "ปิด",
"ui.common.close": "ปิด",
"ui.common.next": "ถัดไป",
"ui.common.continue": "ดำเนินการต่อ",
"ui.common.submit": "ส่ง",
"ui.common.showMore": "แสดงเพิ่มเติม",
+1
View File
@@ -160,6 +160,7 @@ export const dict = {
"ui.common.dismiss": "Işden aýyrmak",
"ui.common.close": ".Akyn",
"ui.common.next": "Indiki",
"ui.common.continue": "Dowam et",
"ui.common.submit": "Iberiň",
"ui.common.showMore": "Has köp görkez",
"ui.permission.deny": "Inkär et",
+1
View File
@@ -178,6 +178,7 @@ export const dict = {
"ui.common.dismiss": "Kapat",
"ui.common.close": "Kapat",
"ui.common.next": "İleri",
"ui.common.continue": "Devam et",
"ui.common.submit": "Gönder",
"ui.common.showMore": "Daha fazla göster",
"ui.common.moreCount.one": "+{{count}} daha",
+1
View File
@@ -207,6 +207,7 @@ export const dict = {
"ui.common.dismiss": "Відхилити",
"ui.common.close": "Закрити",
"ui.common.next": "Далі",
"ui.common.continue": "Продовжити",
"ui.common.submit": "Надіслати",
"ui.common.showMore": "Показати більше",
+1
View File
@@ -161,6 +161,7 @@ export const dict = {
"ui.common.dismiss": "خارج کریں",
"ui.common.close": "بند کریں",
"ui.common.next": "اگلا",
"ui.common.continue": "جاری رکھیں",
"ui.common.submit": "جمع کروائیں۔",
"ui.common.showMore": "مزید دکھائیں",
"ui.permission.deny": "انکار کریں",
+1
View File
@@ -162,6 +162,7 @@ export const dict = {
"ui.common.dismiss": "Rad etish",
"ui.common.close": "Yopish",
"ui.common.next": "Keyingi",
"ui.common.continue": "Davom eting",
"ui.common.submit": "Yuborish",
"ui.common.showMore": "Ko'proq ko'rsatish",
"ui.permission.deny": "Rad etish",
+1
View File
@@ -165,6 +165,7 @@ export const dict = {
"ui.common.dismiss": "Bỏ qua",
"ui.common.close": "Đóng",
"ui.common.next": "Tiếp theo",
"ui.common.continue": "Tiếp tục",
"ui.common.submit": "Gửi",
"ui.common.showMore": "Hiển thị thêm",
"ui.permission.deny": "Từ chối",
+1
View File
@@ -171,6 +171,7 @@ export const dict = {
"ui.common.dismiss": "忽略",
"ui.common.close": "关闭",
"ui.common.next": "下一步",
"ui.common.continue": "继续",
"ui.common.submit": "提交",
"ui.common.showMore": "显示更多",
+1
View File
@@ -171,6 +171,7 @@ export const dict = {
"ui.common.dismiss": "忽略",
"ui.common.close": "關閉",
"ui.common.next": "下一步",
"ui.common.continue": "繼續",
"ui.common.submit": "提交",
"ui.common.showMore": "顯示更多",