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.
This commit is contained in:
AmirHossein Rezaei
2026-07-27 23:54:52 +03:30
committed by GitHub
parent 85128984f9
commit ba28e497fe
11 changed files with 296 additions and 47 deletions

View File

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

View File

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

View File

@@ -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 (~25K100K tokens). ⚠️ Kiro ToS prohibits third-party proxy/harness use.",

View File

@@ -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" && (
<GitlabDuoSetupStep onContinue={() => void startOAuthFlow()} onClose={handleClose} />
)}
{step === "waiting" && !isDeviceCode && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">{t("waiting")}</h3>
<p className="text-sm text-text-muted mb-2">{t("completeAuthInPopup")}</p>
<p className="text-xs text-text-muted mb-4 opacity-70">{t("popupClosedHint")}</p>
<Button variant="ghost" onClick={() => setStep("input")}>
{t("popupBlocked")}
</Button>
</div>
<OAuthWaitingStep
waitingLabel={t("waiting")}
completeAuthLabel={t("completeAuthInPopup")}
popupClosedHint={t("popupClosedHint")}
popupBlockedLabel={t("popupBlocked")}
onManualInput={() => setStep("input")}
/>
)}
{/* Device Code Flow - Waiting */}
@@ -1096,9 +1101,7 @@ export default function OAuthModal({
</div>
)}
{/* 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 && (
<OAuthLoopbackMismatchPanel
providerName={providerInfo.name}
@@ -1107,25 +1110,20 @@ export default function OAuthModal({
/>
)}
{/* Error Step — OAuth errors only; paste-token errors shown inline */}
{step === "error" && !showPasteToken && (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
</div>
<h3 className="text-lg font-semibold mb-2">{t("error")}</h3>
<p className="text-sm text-red-600 mb-4">
<LinkifiedText text={error} />
</p>
<div className="flex gap-2">
<Button onClick={() => startOAuthFlow()} variant="secondary" fullWidth>
{t("tryAgain")}
</Button>
<Button onClick={handleClose} variant="ghost" fullWidth>
{t("cancel")}
</Button>
</div>
</div>
<OAuthErrorStep
error={error}
errorTitle={t("error")}
tryAgainLabel={t("tryAgain")}
cancelLabel={t("cancel")}
returnToGitlabDuoSetup={provider === "gitlab-duo"}
onReturnToGitlabDuoSetup={() => {
setError(null);
setStep("gitlab-duo-setup");
}}
onTryAgain={() => void startOAuthFlow()}
onClose={handleClose}
/>
)}
</div>
</Modal>

View File

@@ -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 (
<div className="flex flex-col gap-4">
<div className="rounded-md border border-border bg-muted/40 px-3 py-3 text-left">
<p className="text-sm font-medium mb-2">GitLab Duo OAuth setup</p>
<p className="text-sm text-text-muted leading-relaxed">
<LinkifiedText text={GITLAB_DUO_OAUTH_SETUP_MESSAGE} />
</p>
</div>
<p className="text-xs text-text-muted">
After the application is registered and the env vars are set on this OmniRoute instance,
click Continue to start the OAuth login.
</p>
<div className="flex gap-2">
<Button onClick={onContinue} fullWidth>
Continue
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
);
}

View File

@@ -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 (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-red-600">error</span>
</div>
<h3 className="text-lg font-semibold mb-2">{errorTitle}</h3>
<p className="text-sm text-red-600 mb-4">
<LinkifiedText text={error} />
</p>
<div className="flex gap-2">
<Button
onClick={() => {
// #8688: return to the setup recipe when still unconfigured,
// instead of immediately re-hitting authorize → same red error.
if (returnToGitlabDuoSetup) {
onReturnToGitlabDuoSetup();
return;
}
onTryAgain();
}}
variant="secondary"
fullWidth
>
{tryAgainLabel}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
{cancelLabel}
</Button>
</div>
</div>
);
}

View File

@@ -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 (
<div className="text-center py-6">
<div className="size-16 mx-auto mb-4 rounded-full bg-primary/10 flex items-center justify-center">
<span className="material-symbols-outlined text-3xl text-primary animate-spin">
progress_activity
</span>
</div>
<h3 className="text-lg font-semibold mb-2">{waitingLabel}</h3>
<p className="text-sm text-text-muted mb-2">{completeAuthLabel}</p>
<p className="text-xs text-text-muted mb-4 opacity-70">{popupClosedHint}</p>
<Button variant="ghost" onClick={onManualInput}>
{popupBlockedLabel}
</Button>
</div>
);
}

View File

@@ -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.";

View File

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

View File

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

View File

@@ -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,
/<Button onClick=\{\(\) => startOAuthFlow\(\)\} variant="secondary" fullWidth>/
errorStepSource,
/<Button\s+onClick=\{\(\) => \{[\s\S]*?onTryAgain\(\);[\s\S]*?\}\}\s+variant="secondary"\s+fullWidth\s*>/
);
});