feat: add GitLab Duo and CodeBuddy support, update observability settings

This commit is contained in:
decolua 2026-03-30 11:28:07 +07:00
parent 11e6004fcb
commit abbf8ec86f
21 changed files with 779 additions and 141 deletions

View file

@ -0,0 +1,194 @@
"use client";
import { useState } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input, OAuthModal } from "@/shared/components";
const GITLAB_COM = "https://gitlab.com";
function getRedirectUri() {
if (typeof window === "undefined") return "http://localhost/callback";
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
return `http://localhost:${port}/callback`;
}
/**
* GitLab Duo Authentication Modal
* Supports two modes:
* - OAuth (PKCE): requires OAuth App Client ID (and optional Client Secret)
* - PAT: requires Personal Access Token
*/
export default function GitLabAuthModal({ isOpen, providerInfo, onSuccess, onClose }) {
const [mode, setMode] = useState(null); // null | "oauth" | "pat"
const [baseUrl, setBaseUrl] = useState(GITLAB_COM);
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [pat, setPat] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [showOAuth, setShowOAuth] = useState(false);
const [oauthMeta, setOauthMeta] = useState(null);
const reset = () => {
setMode(null);
setBaseUrl(GITLAB_COM);
setClientId("");
setClientSecret("");
setPat("");
setError(null);
setLoading(false);
setShowOAuth(false);
setOauthMeta(null);
};
const handleClose = () => {
reset();
onClose();
};
const handleOAuthStart = () => {
if (!clientId.trim()) {
setError("Client ID is required");
return;
}
setError(null);
setOauthMeta({ baseUrl: baseUrl.trim() || GITLAB_COM, clientId: clientId.trim(), clientSecret: clientSecret.trim() });
setShowOAuth(true);
};
const handlePATSubmit = async () => {
if (!pat.trim()) {
setError("Personal Access Token is required");
return;
}
setLoading(true);
setError(null);
try {
const res = await fetch("/api/oauth/gitlab/pat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token: pat.trim(), baseUrl: baseUrl.trim() || GITLAB_COM }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Authentication failed");
onSuccess?.();
handleClose();
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
// Sub-modal for OAuth PKCE flow
if (showOAuth && oauthMeta) {
return (
<OAuthModal
isOpen
provider="gitlab"
providerInfo={providerInfo}
oauthMeta={oauthMeta}
onSuccess={() => { onSuccess?.(); handleClose(); }}
onClose={() => { setShowOAuth(false); setOauthMeta(null); }}
/>
);
}
return (
<Modal isOpen={isOpen} title="Connect GitLab Duo" onClose={handleClose} size="lg">
<div className="flex flex-col gap-4">
{/* Mode selection */}
{!mode && (
<>
<p className="text-sm text-text-muted">
Choose how to authenticate with GitLab Duo:
</p>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setMode("oauth")}
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<span className="material-symbols-outlined text-2xl text-primary">lock_open</span>
<div>
<p className="text-sm font-medium">OAuth App</p>
<p className="text-xs text-text-muted">Use a GitLab OAuth application</p>
</div>
</button>
<button
onClick={() => setMode("pat")}
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-border hover:border-primary hover:bg-primary/5 transition-colors text-left"
>
<span className="material-symbols-outlined text-2xl text-primary">key</span>
<div>
<p className="text-sm font-medium">Personal Access Token</p>
<p className="text-xs text-text-muted">Use a GitLab PAT with api scope</p>
</div>
</button>
</div>
</>
)}
{/* OAuth mode */}
{mode === "oauth" && (
<>
<p className="text-xs text-text-muted">
Create an OAuth app at{" "}
<a href={`${baseUrl.trim() || GITLAB_COM}/-/profile/applications`} target="_blank" rel="noreferrer" className="text-primary underline">
GitLab Applications
</a>{" "}
with redirect URI{" "}
<code className="bg-sidebar px-1 rounded text-xs">{getRedirectUri()}</code>
</p>
<Input label="GitLab Base URL" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={GITLAB_COM} />
<Input label="Client ID" value={clientId} onChange={(e) => setClientId(e.target.value)} placeholder="Your OAuth application client ID" />
<Input label="Client Secret (optional for PKCE)" value={clientSecret} onChange={(e) => setClientSecret(e.target.value)} placeholder="Leave empty for public PKCE app" />
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2">
<Button onClick={handleOAuthStart} fullWidth disabled={!clientId.trim()}>
Authorize
</Button>
<Button onClick={() => { setMode(null); setError(null); }} variant="ghost" fullWidth>
Back
</Button>
</div>
</>
)}
{/* PAT mode */}
{mode === "pat" && (
<>
<p className="text-xs text-text-muted">
Create a PAT at{" "}
<a href={`${baseUrl.trim() || GITLAB_COM}/-/user_settings/personal_access_tokens`} target="_blank" rel="noreferrer" className="text-primary underline">
GitLab Access Tokens
</a>{" "}
with scopes: <code className="bg-sidebar px-1 rounded text-xs">api</code>,{" "}
<code className="bg-sidebar px-1 rounded text-xs">read_user</code>, and{" "}
<code className="bg-sidebar px-1 rounded text-xs">ai_features</code>.
</p>
<Input label="GitLab Base URL" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder={GITLAB_COM} />
<Input label="Personal Access Token" value={pat} onChange={(e) => setPat(e.target.value)} placeholder="glpat-xxxxxxxxxxxxxxxxxxxx" type="password" />
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex gap-2">
<Button onClick={handlePATSubmit} fullWidth disabled={!pat.trim() || loading} loading={loading}>
Connect
</Button>
<Button onClick={() => { setMode(null); setError(null); }} variant="ghost" fullWidth>
Back
</Button>
</div>
</>
)}
</div>
</Modal>
);
}
GitLabAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
providerInfo: PropTypes.shape({ name: PropTypes.string }),
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
};

View file

@ -10,7 +10,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
* - Localhost: Auto callback via popup message
* - Remote: Manual paste callback URL
*/
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose }) {
export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose, oauthMeta }) {
const [step, setStep] = useState("waiting"); // waiting | input | success | error
const [authData, setAuthData] = useState(null);
const [callbackUrl, setCallbackUrl] = useState("");
@ -51,6 +51,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
redirectUri: authData.redirectUri,
codeVerifier: authData.codeVerifier,
state,
...(oauthMeta ? { meta: oauthMeta } : {}),
}),
});
@ -132,7 +133,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
setError(null);
// Device code flow providers
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode"];
const deviceCodeProviders = ["github", "qwen", "kiro", "kimi-coding", "kilocode", "codebuddy"];
if (deviceCodeProviders.includes(provider)) {
setIsDeviceCode(true);
setStep("waiting");
@ -153,18 +154,24 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
return;
}
// Authorization code flow - always use localhost with current port (except Codex)
// Authorization code flow - build redirect URI (some providers require fixed ports)
let redirectUri;
if (provider === "codex") {
// Codex requires fixed port 1455
redirectUri = "http://localhost:1455/auth/callback";
} else {
// Always use localhost with current port for OAuth callback
// Use app's current port for OAuth callback
const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80");
redirectUri = `http://localhost:${port}/callback`;
}
const res = await fetch(`/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`);
// Build authorize URL, optionally passing provider-specific metadata (e.g. gitlab clientId)
const authorizeUrl = new URL(`/api/oauth/${provider}/authorize`, window.location.origin);
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
if (oauthMeta) {
Object.entries(oauthMeta).forEach(([k, v]) => { if (v) authorizeUrl.searchParams.set(k, v); });
}
const res = await fetch(authorizeUrl.toString());
const data = await res.json();
if (!res.ok) throw new Error(data.error);
@ -462,9 +469,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
OAuthModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
provider: PropTypes.string,
providerInfo: PropTypes.shape({
name: PropTypes.string,
}),
providerInfo: PropTypes.shape({ name: PropTypes.string }),
onSuccess: PropTypes.func,
onClose: PropTypes.func.isRequired,
/** Extra metadata passed to /authorize and /exchange (e.g. gitlab clientId/baseUrl) */
oauthMeta: PropTypes.object,
};

View file

@ -26,6 +26,7 @@ export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";
export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Tooltip } from "./Tooltip";