feat: add GitLab Duo and CodeBuddy support, update observability settings
This commit is contained in:
parent
11e6004fcb
commit
abbf8ec86f
21 changed files with 779 additions and 141 deletions
194
src/shared/components/GitLabAuthModal.js
Normal file
194
src/shared/components/GitLabAuthModal.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue