From ba28e497fe69a8f494d303020c36fddf8bd8ca0b Mon Sep 17 00:00:00 2001 From: AmirHossein Rezaei <78272016+DinonowDev@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:54:52 +0330 Subject: [PATCH] fix(oauth): show GitLab Duo setup before authorize error (#8710) * fix(oauth): show GitLab Duo setup before authorize error Surface the OAuth app registration and env-var recipe in the Add Connection modal before auto-starting authorize, and keep the same shared copy for catalog authHint and the authorize fallback (#8688). * fix(oauth): keep OAuthModal under file-size baseline for #8688 Extract waiting/error panels so the GitLab Duo setup step does not trip the Fast Quality Gates file-size ratchet, and update the retry Button regression guard for the extracted error step. --- .../fixes/8688-gitlab-duo-oauth-setup.md | 1 + .../api/oauth/[provider]/[action]/route.ts | 10 +-- src/i18n/messages/en.json | 2 +- src/shared/components/OAuthModal.tsx | 70 +++++++-------- .../oauthModal/GitlabDuoSetupStep.tsx | 40 +++++++++ .../components/oauthModal/OAuthErrorStep.tsx | 60 +++++++++++++ .../oauthModal/OAuthWaitingStep.tsx | 36 ++++++++ src/shared/constants/gitlabDuoSetupMessage.ts | 18 ++++ src/shared/constants/providers/oauth.ts | 6 +- .../unit/gitlab-duo-oauth-setup-8688.test.ts | 89 +++++++++++++++++++ ...oauth-modal-typescript-regressions.test.ts | 11 ++- 11 files changed, 296 insertions(+), 47 deletions(-) create mode 100644 changelog.d/fixes/8688-gitlab-duo-oauth-setup.md create mode 100644 src/shared/components/oauthModal/GitlabDuoSetupStep.tsx create mode 100644 src/shared/components/oauthModal/OAuthErrorStep.tsx create mode 100644 src/shared/components/oauthModal/OAuthWaitingStep.tsx create mode 100644 src/shared/constants/gitlabDuoSetupMessage.ts create mode 100644 tests/unit/gitlab-duo-oauth-setup-8688.test.ts diff --git a/changelog.d/fixes/8688-gitlab-duo-oauth-setup.md b/changelog.d/fixes/8688-gitlab-duo-oauth-setup.md new file mode 100644 index 0000000000..7cdf3643f2 --- /dev/null +++ b/changelog.d/fixes/8688-gitlab-duo-oauth-setup.md @@ -0,0 +1 @@ +- **fix(oauth):** show GitLab Duo OAuth app / env setup instructions in the Add Connection modal _before_ authorize, using a shared recipe with catalog `authHint` and the authorize-route error fallback (#8688) diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index 08d415a452..cd24358f14 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -37,6 +37,7 @@ import { import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { GITLAB_DUO_OAUTH_SETUP_MESSAGE } from "@/shared/constants/gitlabDuoSetupMessage"; import { keychainImportOnlyGuard } from "./keychainImportOnly"; import { buildRemoteOAuthHint } from "./remoteOAuthHint"; @@ -174,17 +175,14 @@ export async function GET( "Qoder browser OAuth is experimental and disabled by default. Configure QODER_OAUTH_* environment variables or use a Personal Access Token.", }); } - // #3861: GitLab Duo needs a self-registered OAuth app. Without a client_id, + // #3861 / #8688: GitLab Duo needs a self-registered OAuth app. Without a client_id, // buildAuthUrl returns null — surface a clear setup message instead of a 500. + // Same copy is shown in the OAuthModal setup step *before* authorize (#8688). if (provider === "gitlab-duo" && !authData.authUrl) { return NextResponse.json({ ...authData, supported: false, - error: - "GitLab Duo OAuth is not configured. Register an OAuth application at " + - "https://gitlab.com/-/profile/applications with redirect URI " + - 'http://localhost:20128/callback and scopes "ai_features read_user", then set ' + - "GITLAB_DUO_OAUTH_CLIENT_ID (and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET) and restart.", + error: GITLAB_DUO_OAUTH_SETUP_MESSAGE, }); } return NextResponse.json(authData); diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 704872ca06..28df815400 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5958,7 +5958,7 @@ "cline": "Connect Cline with the existing OAuth flow.", "cursor": "Connect Cursor IDE with the existing OAuth flow.", "github": "Connect GitHub Copilot with the existing OAuth flow.", - "gitlab-duo": "OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance.", + "gitlab-duo": "GitLab Duo OAuth is not configured. Register an OAuth application at https://gitlab.com/-/profile/applications with redirect URI http://localhost:20128/callback and scopes \"ai_features read_user\", then set GITLAB_DUO_OAUTH_CLIENT_ID (and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET) and restart.", "kilocode": "Connect Kilo Code with the existing OAuth flow.", "kimi-coding": "Connect Kimi Coding with the existing OAuth flow.", "kiro": "Free tier: 50 credits/month (~25K–100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.", diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 939acb483c..c7fc1eb9c9 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -5,7 +5,6 @@ import { useTranslations } from "next-intl"; import Modal from "./Modal"; import Button from "./Button"; import Input from "./Input"; -import LinkifiedText from "./LinkifiedText"; import { OAuthDeviceCodePanel, OAuthLoopbackMismatchPanel, @@ -18,6 +17,9 @@ import { parseCodexSessionJson, } from "@/lib/oauth/utils/codexSessionImport"; import GheConfigStep from "@/shared/components/oauthModal/GheConfigStep"; +import GitlabDuoSetupStep from "@/shared/components/oauthModal/GitlabDuoSetupStep"; +import OAuthErrorStep from "@/shared/components/oauthModal/OAuthErrorStep"; +import OAuthWaitingStep from "@/shared/components/oauthModal/OAuthWaitingStep"; import { parseGrokCliPasteToken } from "@/lib/oauth/utils/grokCliAuthJson"; import { buildGoogleLoopbackHint } from "@/lib/oauth/utils/googleLoopbackHint"; import { @@ -667,6 +669,8 @@ export default function OAuthModal({ if (!isOpen || !provider || flowStartedRef.current) return; flowStartedRef.current = true; const startsInPasteMode = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider); + // #8688: show GitLab Duo OAuth app / env setup before authorize error. + const startsInGitlabDuoSetup = provider === "gitlab-duo"; setShowPasteToken(startsInPasteMode); setGrokBrowserMode(false); setAuthData(null); @@ -675,6 +679,10 @@ export default function OAuthModal({ setIsDeviceCode(false); setDeviceData(null); setPolling(false); + if (startsInGitlabDuoSetup) { + setStep("gitlab-duo-setup"); + return; + } if (!startsInPasteMode) startOAuthFlow(); }, [isOpen, provider, startOAuthFlow]); @@ -1028,21 +1036,18 @@ export default function OAuthModal({ /> )} - {/* Waiting Step (Localhost - popup mode) */} + {provider === "gitlab-duo" && step === "gitlab-duo-setup" && ( + void startOAuthFlow()} onClose={handleClose} /> + )} + {step === "waiting" && !isDeviceCode && ( -
-
- - progress_activity - -
-

{t("waiting")}

-

{t("completeAuthInPopup")}

-

{t("popupClosedHint")}

- -
+ setStep("input")} + /> )} {/* Device Code Flow - Waiting */} @@ -1096,9 +1101,7 @@ export default function OAuthModal({ )} - {/* LAN-IP loopback mismatch (#8046) — a dedicated, actionable panel rather - than the generic red error step: retrying this origin cannot succeed, so - the space goes to the diagnosis + the copy-pasteable tunnel command. */} + {/* LAN-IP loopback mismatch (#8046) — dedicated panel; retrying this origin cannot succeed. */} {step === "loopback-mismatch" && loopbackHint && !showPasteToken && ( )} - {/* Error Step — OAuth errors only; paste-token errors shown inline */} {step === "error" && !showPasteToken && ( -
-
- error -
-

{t("error")}

-

- -

-
- - -
-
+ { + setError(null); + setStep("gitlab-duo-setup"); + }} + onTryAgain={() => void startOAuthFlow()} + onClose={handleClose} + /> )} diff --git a/src/shared/components/oauthModal/GitlabDuoSetupStep.tsx b/src/shared/components/oauthModal/GitlabDuoSetupStep.tsx new file mode 100644 index 0000000000..98114b2e4a --- /dev/null +++ b/src/shared/components/oauthModal/GitlabDuoSetupStep.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Button from "@/shared/components/Button"; +import LinkifiedText from "@/shared/components/LinkifiedText"; +import { GITLAB_DUO_OAUTH_SETUP_MESSAGE } from "@/shared/constants/gitlabDuoSetupMessage"; + +type GitlabDuoSetupStepProps = { + onContinue: () => void; + onClose: () => void; +}; + +/** + * #8688 — Show GitLab Duo OAuth registration / env-var instructions *before* + * the authorize call, so operators are not dumped onto a red error step with + * the only copy of the setup recipe. + */ +export default function GitlabDuoSetupStep({ onContinue, onClose }: GitlabDuoSetupStepProps) { + return ( +
+
+

GitLab Duo OAuth setup

+

+ +

+
+

+ After the application is registered and the env vars are set on this OmniRoute instance, + click Continue to start the OAuth login. +

+
+ + +
+
+ ); +} diff --git a/src/shared/components/oauthModal/OAuthErrorStep.tsx b/src/shared/components/oauthModal/OAuthErrorStep.tsx new file mode 100644 index 0000000000..18a7fe16ab --- /dev/null +++ b/src/shared/components/oauthModal/OAuthErrorStep.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Button from "@/shared/components/Button"; +import LinkifiedText from "@/shared/components/LinkifiedText"; + +type OAuthErrorStepProps = { + error: string | null; + errorTitle: string; + tryAgainLabel: string; + cancelLabel: string; + /** When true, Try Again returns to the GitLab Duo setup recipe (#8688). */ + returnToGitlabDuoSetup: boolean; + onReturnToGitlabDuoSetup: () => void; + onTryAgain: () => void; + onClose: () => void; +}; + +/** Shared OAuth error panel — paste-token errors stay inline in OAuthModal. */ +export default function OAuthErrorStep({ + error, + errorTitle, + tryAgainLabel, + cancelLabel, + returnToGitlabDuoSetup, + onReturnToGitlabDuoSetup, + onTryAgain, + onClose, +}: OAuthErrorStepProps) { + return ( +
+
+ error +
+

{errorTitle}

+

+ +

+
+ + +
+
+ ); +} diff --git a/src/shared/components/oauthModal/OAuthWaitingStep.tsx b/src/shared/components/oauthModal/OAuthWaitingStep.tsx new file mode 100644 index 0000000000..30d9898294 --- /dev/null +++ b/src/shared/components/oauthModal/OAuthWaitingStep.tsx @@ -0,0 +1,36 @@ +"use client"; + +import Button from "@/shared/components/Button"; + +type OAuthWaitingStepProps = { + waitingLabel: string; + completeAuthLabel: string; + popupClosedHint: string; + popupBlockedLabel: string; + onManualInput: () => void; +}; + +/** Localhost popup-mode waiting panel while the OAuth popup completes. */ +export default function OAuthWaitingStep({ + waitingLabel, + completeAuthLabel, + popupClosedHint, + popupBlockedLabel, + onManualInput, +}: OAuthWaitingStepProps) { + return ( +
+
+ + progress_activity + +
+

{waitingLabel}

+

{completeAuthLabel}

+

{popupClosedHint}

+ +
+ ); +} diff --git a/src/shared/constants/gitlabDuoSetupMessage.ts b/src/shared/constants/gitlabDuoSetupMessage.ts new file mode 100644 index 0000000000..61b72d5616 --- /dev/null +++ b/src/shared/constants/gitlabDuoSetupMessage.ts @@ -0,0 +1,18 @@ +/** + * #3861 / #8688 — GitLab Duo needs an operator-registered OAuth application. + * Same copy is used for: + * - catalog `authHint` (pre-connect guidance) + * - OAuthModal setup step (before auto-start) + * - authorize-route error when `GITLAB_DUO_OAUTH_CLIENT_ID` is unset + */ +export const GITLAB_DUO_OAUTH_APPLICATIONS_URL = "https://gitlab.com/-/profile/applications"; + +export const GITLAB_DUO_OAUTH_DEFAULT_REDIRECT_URI = "http://localhost:20128/callback"; + +export const GITLAB_DUO_OAUTH_SCOPES = "ai_features read_user"; + +export const GITLAB_DUO_OAUTH_SETUP_MESSAGE = + "GitLab Duo OAuth is not configured. Register an OAuth application at " + + `${GITLAB_DUO_OAUTH_APPLICATIONS_URL} with redirect URI ` + + `${GITLAB_DUO_OAUTH_DEFAULT_REDIRECT_URI} and scopes "${GITLAB_DUO_OAUTH_SCOPES}", then set ` + + "GITLAB_DUO_OAUTH_CLIENT_ID (and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET) and restart."; diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index f39169d287..80471f3d1d 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -2,6 +2,8 @@ * Provider catalog data — extracted from providers.ts (god-file decomposition). * Pure data literal; re-exported by the providers.ts barrel. No behavior change. */ +import { GITLAB_DUO_OAUTH_SETUP_MESSAGE } from "@/shared/constants/gitlabDuoSetupMessage"; + export const OAUTH_PROVIDERS = { "ghe-copilot": { id: "ghe-copilot", @@ -122,8 +124,8 @@ export const OAUTH_PROVIDERS = { color: "#FC6D26", textIcon: "GL", website: "https://docs.gitlab.com/user/duo_agent_platform/code_suggestions/", - authHint: - "OAuth application with ai_features + read_user scopes. Configure GITLAB_DUO_OAUTH_CLIENT_ID and optionally GITLAB_DUO_OAUTH_CLIENT_SECRET on this OmniRoute instance.", + // #8688: full actionable recipe (same string as authorize error + OAuthModal setup step) + authHint: GITLAB_DUO_OAUTH_SETUP_MESSAGE, }, cursor: { id: "cursor", diff --git a/tests/unit/gitlab-duo-oauth-setup-8688.test.ts b/tests/unit/gitlab-duo-oauth-setup-8688.test.ts new file mode 100644 index 0000000000..dee8a84999 --- /dev/null +++ b/tests/unit/gitlab-duo-oauth-setup-8688.test.ts @@ -0,0 +1,89 @@ +/** + * #8688 — GitLab Duo OAuth setup must be visible *before* the authorize error step. + * + * Previously Add Connection auto-started OAuth and the only copy of the registration + * recipe lived in the red error body. The setup step + shared message keep that + * recipe actionable up front; authorize still returns the same string as fallback. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { + GITLAB_DUO_OAUTH_APPLICATIONS_URL, + GITLAB_DUO_OAUTH_DEFAULT_REDIRECT_URI, + GITLAB_DUO_OAUTH_SCOPES, + GITLAB_DUO_OAUTH_SETUP_MESSAGE, +} from "../../src/shared/constants/gitlabDuoSetupMessage"; +import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers/oauth"; + +const here = dirname(fileURLToPath(import.meta.url)); +const read = (rel: string) => readFileSync(resolve(here, rel), "utf8"); + +test("#8688 setup message embeds registration URL, redirect, scopes, and env vars", () => { + assert.match(GITLAB_DUO_OAUTH_SETUP_MESSAGE, /GitLab Duo OAuth is not configured/); + assert.equal(GITLAB_DUO_OAUTH_APPLICATIONS_URL, "https://gitlab.com/-/profile/applications"); + assert.ok(GITLAB_DUO_OAUTH_SETUP_MESSAGE.includes(GITLAB_DUO_OAUTH_APPLICATIONS_URL)); + assert.ok(GITLAB_DUO_OAUTH_SETUP_MESSAGE.includes(GITLAB_DUO_OAUTH_DEFAULT_REDIRECT_URI)); + assert.ok(GITLAB_DUO_OAUTH_SETUP_MESSAGE.includes(`"${GITLAB_DUO_OAUTH_SCOPES}"`)); + assert.match(GITLAB_DUO_OAUTH_SETUP_MESSAGE, /GITLAB_DUO_OAUTH_CLIENT_ID/); + assert.match(GITLAB_DUO_OAUTH_SETUP_MESSAGE, /GITLAB_DUO_OAUTH_CLIENT_SECRET/); +}); + +test("#8688 catalog authHint shares the authorize / modal setup message", () => { + assert.equal( + OAUTH_PROVIDERS["gitlab-duo"].authHint, + GITLAB_DUO_OAUTH_SETUP_MESSAGE, + "authHint must stay byte-identical to GITLAB_DUO_OAUTH_SETUP_MESSAGE" + ); +}); + +test("#8688 authorize route returns the shared setup message on missing client_id", () => { + const route = read("../../src/app/api/oauth/[provider]/[action]/route.ts"); + assert.match(route, /GITLAB_DUO_OAUTH_SETUP_MESSAGE/); + assert.match(route, /provider === "gitlab-duo" && !authData\.authUrl/); + // No inline duplicate of the long recipe — single source of truth. + assert.doesNotMatch(route, /Register an OAuth application at " \+\s*"https:\/\/gitlab\.com/); +}); + +test("#8688 OAuthModal skips auto-start and renders GitlabDuoSetupStep (#8688)", () => { + const modal = read("../../src/shared/components/OAuthModal.tsx"); + assert.match(modal, /GitlabDuoSetupStep/); + assert.match(modal, /startsInGitlabDuoSetup/); + assert.match(modal, /setStep\("gitlab-duo-setup"\)/); + assert.match(modal, /step === "gitlab-duo-setup"/); + + // Auto-start must be skipped when the setup step is active. + const openEffect = modal.match( + /\/\/ Reset state and start OAuth when modal opens[\s\S]*?}, \[isOpen, provider, startOAuthFlow\]\);/ + ); + assert.ok(openEffect, "expected the modal-open useEffect"); + assert.match(openEffect![0], /startsInGitlabDuoSetup/); + assert.match(openEffect![0], /setStep\("gitlab-duo-setup"\)/); + assert.match(openEffect![0], /return;/); + assert.match( + openEffect![0], + /if \(!startsInPasteMode\) startOAuthFlow\(\)/, + "other providers still auto-start" + ); + + const setup = read("../../src/shared/components/oauthModal/GitlabDuoSetupStep.tsx"); + assert.match(setup, /GITLAB_DUO_OAUTH_SETUP_MESSAGE/); + assert.match(setup, /LinkifiedText/); + assert.match(setup, /Continue/); +}); + +test("#8688 error Try Again returns gitlab-duo to the setup step", () => { + const modal = read("../../src/shared/components/OAuthModal.tsx"); + assert.match(modal, /returnToGitlabDuoSetup=\{provider === "gitlab-duo"\}/); + assert.match(modal, /setStep\("gitlab-duo-setup"\)/); + + const errorStep = read("../../src/shared/components/oauthModal/OAuthErrorStep.tsx"); + assert.match(errorStep, /returnToGitlabDuoSetup/); + assert.match(errorStep, /onReturnToGitlabDuoSetup/); + assert.match( + errorStep, + /if \(returnToGitlabDuoSetup\)[\s\S]{0,80}?onReturnToGitlabDuoSetup\(\)/ + ); +}); diff --git a/tests/unit/oauth-modal-typescript-regressions.test.ts b/tests/unit/oauth-modal-typescript-regressions.test.ts index 167adfd432..963e271b67 100644 --- a/tests/unit/oauth-modal-typescript-regressions.test.ts +++ b/tests/unit/oauth-modal-typescript-regressions.test.ts @@ -3,14 +3,21 @@ import { readFileSync } from "node:fs"; import test from "node:test"; const modalSource = readFileSync("src/shared/components/OAuthModal.tsx", "utf8"); +const errorStepSource = readFileSync( + "src/shared/components/oauthModal/OAuthErrorStep.tsx", + "utf8" +); test("OAuthModal narrows failed Grok paste results before reading the error", () => { assert.match(modalSource, /if \(parsed\.ok === false\) \{\s*setError\(parsed\.error\);/); }); test("OAuthModal adapts the retry action to the button click handler", () => { + // #8688 extracted the error panel; retry stays a Button onClick in OAuthErrorStep, + // wired from OAuthModal via onTryAgain → startOAuthFlow. + assert.match(modalSource, /onTryAgain=\{\(\) => void startOAuthFlow\(\)\}/); assert.match( - modalSource, - /