From 2ef38b2172d646c2813aee56192e7f17dd97922a Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:51:46 +0100 Subject: [PATCH] fix(app): accept a pasted redirect URL for provider sign-in (#63315) Co-authored-by: Aiden Cline --- .../app/src/providers/connect/controller.ts | 12 ++ packages/app/src/providers/connect/dialog.tsx | 25 +++- .../app/src/providers/connect/machine.test.ts | 123 ++++++++++++++++++ packages/app/src/providers/connect/machine.ts | 87 ++++++++++++- .../app/src/providers/connect/mcp-sign-in.tsx | 59 ++------- packages/app/src/runtime/i18n/en.ts | 5 +- packages/core/src/mcp/oauth.ts | 23 +--- packages/core/src/oauth/redirect.ts | 42 ++++++ packages/core/src/plugin/provider/chatgpt.ts | 13 +- packages/core/src/plugin/provider/gitlab.ts | 9 +- packages/core/src/plugin/provider/openai.ts | 2 + packages/core/src/plugin/provider/poe.ts | 19 ++- .../src/plugin/provider/snowflake-cortex.ts | 2 + .../core/test/plugin/provider-poe.test.ts | 28 ++++ packages/gui-plugins/src/details/i18n/en.ts | 3 - .../gui-plugins/src/details/server-panel.tsx | 58 ++------- packages/plugin/src/effect/integration.ts | 5 + packages/plugin/src/promise/adapter.ts | 28 ++-- packages/plugin/src/promise/integration.ts | 5 + packages/ui/package.json | 1 + .../forms/redirect-paste/redirect-paste.tsx | 75 +++++++++++ packages/ui/src/i18n/am.ts | 1 + packages/ui/src/i18n/ar.ts | 1 + packages/ui/src/i18n/az.ts | 1 + packages/ui/src/i18n/bg.ts | 1 + packages/ui/src/i18n/bn.ts | 1 + packages/ui/src/i18n/br.ts | 1 + packages/ui/src/i18n/bs.ts | 1 + packages/ui/src/i18n/ca.ts | 1 + packages/ui/src/i18n/cs.ts | 1 + packages/ui/src/i18n/da.ts | 1 + packages/ui/src/i18n/de.ts | 1 + packages/ui/src/i18n/dv.ts | 1 + packages/ui/src/i18n/dz.ts | 1 + packages/ui/src/i18n/el.ts | 1 + packages/ui/src/i18n/en.ts | 5 + packages/ui/src/i18n/es.ts | 1 + packages/ui/src/i18n/et.ts | 1 + packages/ui/src/i18n/fa.ts | 1 + packages/ui/src/i18n/fi.ts | 1 + packages/ui/src/i18n/fo.ts | 1 + packages/ui/src/i18n/fr.ts | 1 + packages/ui/src/i18n/he.ts | 1 + packages/ui/src/i18n/hi.ts | 1 + packages/ui/src/i18n/hr.ts | 1 + packages/ui/src/i18n/hu.ts | 1 + packages/ui/src/i18n/hy.ts | 1 + packages/ui/src/i18n/id.ts | 1 + packages/ui/src/i18n/is.ts | 1 + packages/ui/src/i18n/it.ts | 1 + packages/ui/src/i18n/ja.ts | 1 + packages/ui/src/i18n/ka.ts | 1 + packages/ui/src/i18n/km.ts | 1 + packages/ui/src/i18n/ko.ts | 1 + packages/ui/src/i18n/lo.ts | 1 + packages/ui/src/i18n/lt.ts | 1 + packages/ui/src/i18n/lv.ts | 1 + packages/ui/src/i18n/mk.ts | 1 + packages/ui/src/i18n/mn.ts | 1 + packages/ui/src/i18n/ms.ts | 1 + packages/ui/src/i18n/my.ts | 1 + packages/ui/src/i18n/ne.ts | 1 + packages/ui/src/i18n/nl.ts | 1 + packages/ui/src/i18n/no.ts | 1 + packages/ui/src/i18n/pa.ts | 1 + packages/ui/src/i18n/pl.ts | 1 + packages/ui/src/i18n/ro.ts | 1 + packages/ui/src/i18n/ru.ts | 1 + packages/ui/src/i18n/si.ts | 1 + packages/ui/src/i18n/sk.ts | 1 + packages/ui/src/i18n/sl.ts | 1 + packages/ui/src/i18n/sq.ts | 1 + packages/ui/src/i18n/sr.ts | 1 + packages/ui/src/i18n/sv.ts | 1 + packages/ui/src/i18n/tg.ts | 1 + packages/ui/src/i18n/th.ts | 1 + packages/ui/src/i18n/tk.ts | 1 + packages/ui/src/i18n/tr.ts | 1 + packages/ui/src/i18n/uk.ts | 1 + packages/ui/src/i18n/ur.ts | 1 + packages/ui/src/i18n/uz.ts | 1 + packages/ui/src/i18n/vi.ts | 1 + packages/ui/src/i18n/zh.ts | 1 + packages/ui/src/i18n/zht.ts | 1 + 84 files changed, 547 insertions(+), 144 deletions(-) create mode 100644 packages/core/src/oauth/redirect.ts create mode 100644 packages/ui/src/forms/redirect-paste/redirect-paste.tsx diff --git a/packages/app/src/providers/connect/controller.ts b/packages/app/src/providers/connect/controller.ts index 4fe7ea1fbce..d910d71d51b 100644 --- a/packages/app/src/providers/connect/controller.ts +++ b/packages/app/src/providers/connect/controller.ts @@ -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) diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx index 5e07a913da5..469e414481e 100644 --- a/packages/app/src/providers/connect/dialog.tsx +++ b/packages/app/src/providers/connect/dialog.tsx @@ -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 (
-
{language.t("provider.connect.oauth.auto.description", { provider: name() })}
+
+ {language.t( + connect().paste + ? "provider.connect.oauth.auto.paste.description" + : "provider.connect.oauth.auto.description", + { provider: name() }, + )} +
{language.t("provider.connect.status.waiting")}
) } diff --git a/packages/app/src/providers/connect/machine.test.ts b/packages/app/src/providers/connect/machine.test.ts index ad4f8e4f990..e9500f77fe6 100644 --- a/packages/app/src/providers/connect/machine.test.ts +++ b/packages/app/src/providers/connect/machine.test.ts @@ -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 }) diff --git a/packages/app/src/providers/connect/machine.ts b/packages/app/src/providers/connect/machine.ts index 0b22cdb1153..171bcc45141 100644 --- a/packages/app/src/providers/connect/machine.ts +++ b/packages/app/src/providers/connect/machine.ts @@ -33,6 +33,8 @@ export type ProviderSignIn = readonly method: Extract 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 } @@ -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 + 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, 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, + 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, 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 }], } } diff --git a/packages/app/src/providers/connect/mcp-sign-in.tsx b/packages/app/src/providers/connect/mcp-sign-in.tsx index 5f7c9492507..da4f1a75444 100644 --- a/packages/app/src/providers/connect/mcp-sign-in.tsx +++ b/packages/app/src/providers/connect/mcp-sign-in.tsx @@ -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 ( @@ -107,40 +95,15 @@ export function DialogMcpSignIn(props: {
-
{ - event.preventDefault() - dispatch({ type: "paste.submitted" }) - }} - class="flex flex-col items-start gap-5 self-stretch" - > - - - {(error) => ( - - )} - - -
+ dispatch({ type: "paste.changed", value })} + onSubmit={() => dispatch({ type: "paste.submitted" })} + />
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 755e5f8201a..d7f2f560bed 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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}}", diff --git a/packages/core/src/mcp/oauth.ts b/packages/core/src/mcp/oauth.ts index fe0972303f9..ec0284201c2 100644 --- a/packages/core/src/mcp/oauth.ts +++ b/packages/core/src/mcp/oauth.ts @@ -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) => diff --git a/packages/core/src/oauth/redirect.ts b/packages/core/src/oauth/redirect.ts new file mode 100644 index 00000000000..3f19e3eeae8 --- /dev/null +++ b/packages/core/src/oauth/redirect.ts @@ -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 = + (input: { + state: string + callback: Deferred.Deferred + 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" diff --git a/packages/core/src/plugin/provider/chatgpt.ts b/packages/core/src/plugin/provider/chatgpt.ts index a7738b86e5b..85ad3926df2 100644 --- a/packages/core/src/plugin/provider/chatgpt.ts +++ b/packages/core/src/plugin/provider/chatgpt.ts @@ -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), diff --git a/packages/core/src/plugin/provider/gitlab.ts b/packages/core/src/plugin/provider/gitlab.ts index e266316a989..a750903c9bc 100644 --- a/packages/core/src/plugin/provider/gitlab.ts +++ b/packages/core/src/plugin/provider/gitlab.ts @@ -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 }) }), } }), }) diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index a03e3e35f26..7a9cecf7a0b 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -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), diff --git a/packages/core/src/plugin/provider/poe.ts b/packages/core/src/plugin/provider/poe.ts index 24edb14757f..f7831c65c32 100644 --- a/packages/core/src/plugin/provider/poe.ts +++ b/packages/core/src/plugin/provider/poe.ts @@ -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, + ), } }), }) diff --git a/packages/core/src/plugin/provider/snowflake-cortex.ts b/packages/core/src/plugin/provider/snowflake-cortex.ts index 1745b87df12..32a45a5001b 100644 --- a/packages/core/src/plugin/provider/snowflake-cortex.ts +++ b/packages/core/src/plugin/provider/snowflake-cortex.ts @@ -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 }), } }), }) diff --git a/packages/core/test/plugin/provider-poe.test.ts b/packages/core/test/plugin/provider-poe.test.ts index 6b3935ca724..89c51afe7bb 100644 --- a/packages/core/test/plugin/provider-poe.test.ts +++ b/packages/core/test/plugin/provider-poe.test.ts @@ -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 diff --git a/packages/gui-plugins/src/details/i18n/en.ts b/packages/gui-plugins/src/details/i18n/en.ts index b943e47d6b6..6d627d07db0 100644 --- a/packages/gui-plugins/src/details/i18n/en.ts +++ b/packages/gui-plugins/src/details/i18n/en.ts @@ -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}}", diff --git a/packages/gui-plugins/src/details/server-panel.tsx b/packages/gui-plugins/src/details/server-panel.tsx index aae341070f3..b26c31ce950 100644 --- a/packages/gui-plugins/src/details/server-panel.tsx +++ b/packages/gui-plugins/src/details/server-panel.tsx @@ -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 ( @@ -1628,40 +1615,15 @@ function McpSignInDialog(props: { -
{ - event.preventDefault() - dispatch({ type: "paste.submitted" }) - }} - class="flex flex-col items-start gap-5 self-stretch" - > - - - {(error) => ( - - )} - - -
+ dispatch({ type: "paste.changed", value })} + onSubmit={() => dispatch({ type: "paste.submitted" })} + />
diff --git a/packages/plugin/src/effect/integration.ts b/packages/plugin/src/effect/integration.ts index dea0f616781..0c333da71ed 100644 --- a/packages/plugin/src/effect/integration.ts +++ b/packages/plugin/src/effect/integration.ts @@ -55,6 +55,11 @@ export type IntegrationOAuthAuthorization = { | { readonly mode: "auto" readonly callback: Effect.Effect + /** + * 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 } | { readonly mode: "code" diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 304fc2e46d5..ba81e6ee224 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -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 diff --git a/packages/plugin/src/promise/integration.ts b/packages/plugin/src/promise/integration.ts index 630b391826a..32fef404195 100644 --- a/packages/plugin/src/promise/integration.ts +++ b/packages/plugin/src/promise/integration.ts @@ -54,6 +54,11 @@ export type IntegrationOAuthAuthorization = { | { readonly mode: "auto" readonly callback: Promise + /** + * 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 } | { readonly mode: "code" diff --git a/packages/ui/package.json b/packages/ui/package.json index a7c88e12eef..18fddd26c79 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/forms/redirect-paste/redirect-paste.tsx b/packages/ui/src/forms/redirect-paste/redirect-paste.tsx new file mode 100644 index 00000000000..1858edcf50d --- /dev/null +++ b/packages/ui/src/forms/redirect-paste/redirect-paste.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 ( +
{ + event.preventDefault() + props.onSubmit() + }} + class="flex flex-col items-start gap-5 self-stretch" + > + + + {(message) => ( + + )} + + +
+ ) +} diff --git a/packages/ui/src/i18n/am.ts b/packages/ui/src/i18n/am.ts index 44ff6a690b0..2f9f9519eb4 100644 --- a/packages/ui/src/i18n/am.ts +++ b/packages/ui/src/i18n/am.ts @@ -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": "ክድ", diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index e5cc8bf369f..421f2f98cfb 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -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": "عرض المزيد", diff --git a/packages/ui/src/i18n/az.ts b/packages/ui/src/i18n/az.ts index deaec104aa8..d97a3f65f35 100644 --- a/packages/ui/src/i18n/az.ts +++ b/packages/ui/src/i18n/az.ts @@ -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", diff --git a/packages/ui/src/i18n/bg.ts b/packages/ui/src/i18n/bg.ts index c1236a4e0d7..04914bf539b 100644 --- a/packages/ui/src/i18n/bg.ts +++ b/packages/ui/src/i18n/bg.ts @@ -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": "Откажи", diff --git a/packages/ui/src/i18n/bn.ts b/packages/ui/src/i18n/bn.ts index 765a12aa123..c251091c1d0 100644 --- a/packages/ui/src/i18n/bn.ts +++ b/packages/ui/src/i18n/bn.ts @@ -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": "অস্বীকার করুন", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index a0c4d4ed6bf..21e3a7cf640 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -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", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 26c8a7dce86..d054ead619a 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -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", diff --git a/packages/ui/src/i18n/ca.ts b/packages/ui/src/i18n/ca.ts index 0f5997cd4c3..a1c40131cb7 100644 --- a/packages/ui/src/i18n/ca.ts +++ b/packages/ui/src/i18n/ca.ts @@ -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", diff --git a/packages/ui/src/i18n/cs.ts b/packages/ui/src/i18n/cs.ts index 952f3ca1a8d..b393e2a1c3f 100644 --- a/packages/ui/src/i18n/cs.ts +++ b/packages/ui/src/i18n/cs.ts @@ -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", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index d3537da4c79..0895933f029 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -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", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 5cf1228ce6f..3cb2ea4fe30 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -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", diff --git a/packages/ui/src/i18n/dv.ts b/packages/ui/src/i18n/dv.ts index f11af675e36..d522c5ebb60 100644 --- a/packages/ui/src/i18n/dv.ts +++ b/packages/ui/src/i18n/dv.ts @@ -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": "ދޮގުކުރުން", diff --git a/packages/ui/src/i18n/dz.ts b/packages/ui/src/i18n/dz.ts index 1801cfa222e..a17f6c86bac 100644 --- a/packages/ui/src/i18n/dz.ts +++ b/packages/ui/src/i18n/dz.ts @@ -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}} མངམ།", diff --git a/packages/ui/src/i18n/el.ts b/packages/ui/src/i18n/el.ts index f45159d5085..0225f8e7cf6 100644 --- a/packages/ui/src/i18n/el.ts +++ b/packages/ui/src/i18n/el.ts @@ -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": "Απόρριψη", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 662e32dddee..abb64d677e6 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -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", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index ff787f9b0a0..b719ce71ce6 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -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", diff --git a/packages/ui/src/i18n/et.ts b/packages/ui/src/i18n/et.ts index aa2523f8a13..878cf376cb9 100644 --- a/packages/ui/src/i18n/et.ts +++ b/packages/ui/src/i18n/et.ts @@ -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", diff --git a/packages/ui/src/i18n/fa.ts b/packages/ui/src/i18n/fa.ts index 560317df8a2..19ae7e51693 100644 --- a/packages/ui/src/i18n/fa.ts +++ b/packages/ui/src/i18n/fa.ts @@ -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": "تکذیب کنید", diff --git a/packages/ui/src/i18n/fi.ts b/packages/ui/src/i18n/fi.ts index 44bf27a10f8..dea11b678bb 100644 --- a/packages/ui/src/i18n/fi.ts +++ b/packages/ui/src/i18n/fi.ts @@ -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ä", diff --git a/packages/ui/src/i18n/fo.ts b/packages/ui/src/i18n/fo.ts index cdc1f6f5ea3..9396916c1f6 100644 --- a/packages/ui/src/i18n/fo.ts +++ b/packages/ui/src/i18n/fo.ts @@ -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", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index e9cc1c6db22..a8e160e93a9 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -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", diff --git a/packages/ui/src/i18n/he.ts b/packages/ui/src/i18n/he.ts index 52266be75ee..82d4addfb30 100644 --- a/packages/ui/src/i18n/he.ts +++ b/packages/ui/src/i18n/he.ts @@ -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": "דחה", diff --git a/packages/ui/src/i18n/hi.ts b/packages/ui/src/i18n/hi.ts index 08227e78621..fcf9c247f62 100644 --- a/packages/ui/src/i18n/hi.ts +++ b/packages/ui/src/i18n/hi.ts @@ -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": "अस्वीकार करें", diff --git a/packages/ui/src/i18n/hr.ts b/packages/ui/src/i18n/hr.ts index 17eb5cd7d13..55090d30f2b 100644 --- a/packages/ui/src/i18n/hr.ts +++ b/packages/ui/src/i18n/hr.ts @@ -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", diff --git a/packages/ui/src/i18n/hu.ts b/packages/ui/src/i18n/hu.ts index 79ef2dbb7f8..1f504a1a671 100644 --- a/packages/ui/src/i18n/hu.ts +++ b/packages/ui/src/i18n/hu.ts @@ -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", diff --git a/packages/ui/src/i18n/hy.ts b/packages/ui/src/i18n/hy.ts index 9ba24a21d0b..8ae56ddaf61 100644 --- a/packages/ui/src/i18n/hy.ts +++ b/packages/ui/src/i18n/hy.ts @@ -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": "Մերժել", diff --git a/packages/ui/src/i18n/id.ts b/packages/ui/src/i18n/id.ts index f451a2ca96a..dc59f3faa75 100644 --- a/packages/ui/src/i18n/id.ts +++ b/packages/ui/src/i18n/id.ts @@ -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", diff --git a/packages/ui/src/i18n/is.ts b/packages/ui/src/i18n/is.ts index 5ec6744c881..9285aa7f0ac 100644 --- a/packages/ui/src/i18n/is.ts +++ b/packages/ui/src/i18n/is.ts @@ -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", diff --git a/packages/ui/src/i18n/it.ts b/packages/ui/src/i18n/it.ts index 2ef272b84ad..a39b7406d80 100644 --- a/packages/ui/src/i18n/it.ts +++ b/packages/ui/src/i18n/it.ts @@ -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", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 0126dff9dfa..0b90e36fda2 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -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": "さらに表示", diff --git a/packages/ui/src/i18n/ka.ts b/packages/ui/src/i18n/ka.ts index f91a09346ee..d41bb3311d6 100644 --- a/packages/ui/src/i18n/ka.ts +++ b/packages/ui/src/i18n/ka.ts @@ -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": "უარი", diff --git a/packages/ui/src/i18n/km.ts b/packages/ui/src/i18n/km.ts index fd118b4793d..6a2656f8267 100644 --- a/packages/ui/src/i18n/km.ts +++ b/packages/ui/src/i18n/km.ts @@ -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": "បដិសេធ", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index c85dda20d67..ef33c49c2bb 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -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": "더 보기", diff --git a/packages/ui/src/i18n/lo.ts b/packages/ui/src/i18n/lo.ts index f52992c5082..844b78ea459 100644 --- a/packages/ui/src/i18n/lo.ts +++ b/packages/ui/src/i18n/lo.ts @@ -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": "ປະຕິເສດ", diff --git a/packages/ui/src/i18n/lt.ts b/packages/ui/src/i18n/lt.ts index 077c76e2425..68ef43d6727 100644 --- a/packages/ui/src/i18n/lt.ts +++ b/packages/ui/src/i18n/lt.ts @@ -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", diff --git a/packages/ui/src/i18n/lv.ts b/packages/ui/src/i18n/lv.ts index 30ddb39ced0..b0389dc508f 100644 --- a/packages/ui/src/i18n/lv.ts +++ b/packages/ui/src/i18n/lv.ts @@ -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", diff --git a/packages/ui/src/i18n/mk.ts b/packages/ui/src/i18n/mk.ts index b88759f8981..18b5fa40bea 100644 --- a/packages/ui/src/i18n/mk.ts +++ b/packages/ui/src/i18n/mk.ts @@ -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": "Негирајте", diff --git a/packages/ui/src/i18n/mn.ts b/packages/ui/src/i18n/mn.ts index 6c25a079118..0e9527fa96c 100644 --- a/packages/ui/src/i18n/mn.ts +++ b/packages/ui/src/i18n/mn.ts @@ -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": "Үгүйсгэх", diff --git a/packages/ui/src/i18n/ms.ts b/packages/ui/src/i18n/ms.ts index a99c8ccc074..5b06d0a12fe 100644 --- a/packages/ui/src/i18n/ms.ts +++ b/packages/ui/src/i18n/ms.ts @@ -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", diff --git a/packages/ui/src/i18n/my.ts b/packages/ui/src/i18n/my.ts index 0f117f41095..f6a4c8da3ba 100644 --- a/packages/ui/src/i18n/my.ts +++ b/packages/ui/src/i18n/my.ts @@ -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": "ငြင်းဆို", diff --git a/packages/ui/src/i18n/ne.ts b/packages/ui/src/i18n/ne.ts index 8869c7398ff..4e2be700462 100644 --- a/packages/ui/src/i18n/ne.ts +++ b/packages/ui/src/i18n/ne.ts @@ -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": "अस्वीकार गर्नुहोस्", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index b7156f4f404..f96f8995068 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -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", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index 3eb01c91591..40bb03e8011 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -181,6 +181,7 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/i18n/pa.ts b/packages/ui/src/i18n/pa.ts index e7de31bcc11..da5ce835007 100644 --- a/packages/ui/src/i18n/pa.ts +++ b/packages/ui/src/i18n/pa.ts @@ -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": "منع کرو", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index fa524308abd..e0ba6029808 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -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ść", diff --git a/packages/ui/src/i18n/ro.ts b/packages/ui/src/i18n/ro.ts index 391fe5cf8cf..75220fd840a 100644 --- a/packages/ui/src/i18n/ro.ts +++ b/packages/ui/src/i18n/ro.ts @@ -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ă", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index b06cd97435b..0205c97b33c 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -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": "Очистить", diff --git a/packages/ui/src/i18n/si.ts b/packages/ui/src/i18n/si.ts index c0d28b82475..3836c9aea4e 100644 --- a/packages/ui/src/i18n/si.ts +++ b/packages/ui/src/i18n/si.ts @@ -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": "ප්‍රතික්ෂේප කරන්න", diff --git a/packages/ui/src/i18n/sk.ts b/packages/ui/src/i18n/sk.ts index 7236ae2ac26..f62c2f95947 100644 --- a/packages/ui/src/i18n/sk.ts +++ b/packages/ui/src/i18n/sk.ts @@ -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ť", diff --git a/packages/ui/src/i18n/sl.ts b/packages/ui/src/i18n/sl.ts index c7bccbc634f..72604e59a8f 100644 --- a/packages/ui/src/i18n/sl.ts +++ b/packages/ui/src/i18n/sl.ts @@ -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", diff --git a/packages/ui/src/i18n/sq.ts b/packages/ui/src/i18n/sq.ts index eb2d3d0fe07..ce648c191f5 100644 --- a/packages/ui/src/i18n/sq.ts +++ b/packages/ui/src/i18n/sq.ts @@ -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", diff --git a/packages/ui/src/i18n/sr.ts b/packages/ui/src/i18n/sr.ts index 6129318c140..b7920fc89cb 100644 --- a/packages/ui/src/i18n/sr.ts +++ b/packages/ui/src/i18n/sr.ts @@ -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": "Дени", diff --git a/packages/ui/src/i18n/sv.ts b/packages/ui/src/i18n/sv.ts index de449a20294..685b8ffe6f2 100644 --- a/packages/ui/src/i18n/sv.ts +++ b/packages/ui/src/i18n/sv.ts @@ -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", diff --git a/packages/ui/src/i18n/tg.ts b/packages/ui/src/i18n/tg.ts index 8df9548fc6f..680bc7fb213 100644 --- a/packages/ui/src/i18n/tg.ts +++ b/packages/ui/src/i18n/tg.ts @@ -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": "Инкор кардан", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 31ccf0cd237..c73702a5926 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -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": "แสดงเพิ่มเติม", diff --git a/packages/ui/src/i18n/tk.ts b/packages/ui/src/i18n/tk.ts index 7864f511234..a4ba87a31e3 100644 --- a/packages/ui/src/i18n/tk.ts +++ b/packages/ui/src/i18n/tk.ts @@ -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", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index 214be818445..cd706f4420e 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -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", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index c32fbeb78d5..86650c1bf47 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -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": "Показати більше", diff --git a/packages/ui/src/i18n/ur.ts b/packages/ui/src/i18n/ur.ts index 4deb78bdef3..6fffabee96d 100644 --- a/packages/ui/src/i18n/ur.ts +++ b/packages/ui/src/i18n/ur.ts @@ -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": "انکار کریں", diff --git a/packages/ui/src/i18n/uz.ts b/packages/ui/src/i18n/uz.ts index 87267d8e4d8..34b4fb1ffbd 100644 --- a/packages/ui/src/i18n/uz.ts +++ b/packages/ui/src/i18n/uz.ts @@ -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", diff --git a/packages/ui/src/i18n/vi.ts b/packages/ui/src/i18n/vi.ts index f149f7889e4..7dba452b196 100644 --- a/packages/ui/src/i18n/vi.ts +++ b/packages/ui/src/i18n/vi.ts @@ -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", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index b6c00e73888..0fd0399efd2 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -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": "显示更多", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index de7b083c097..49fd6b485f5 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -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": "顯示更多",