Files
doktainer-upstream/src/app/git/components/GitProvidersPanel.tsx
T
2026-06-02 00:17:50 +07:00

1350 lines
45 KiB
TypeScript

"use client";
import { type MouseEvent, useEffect, useMemo, useState } from "react";
import {
ChevronRight,
Edit3,
ExternalLink,
GitBranch,
Globe2,
Loader2,
MoreVertical,
Pencil,
Plus,
Power,
RefreshCw,
Search,
ShieldCheck,
TestTube2,
Trash2,
Users,
X,
} from "lucide-react";
import TablePagination from "@/components/TablePagination";
import type { GitProviderRecord, GitProviderType } from "@/lib/api";
import BitbucketProviderModal from "@/app/git/components/providers/BitbucketProviderModal";
import GiteaProviderModal from "@/app/git/components/providers/GiteaProviderModal";
import GithubProviderModal from "@/app/git/components/providers/GithubProviderModal";
import GitlabProviderModal from "@/app/git/components/providers/GitlabProviderModal";
import {
type GitProviderDraft,
PROVIDER_OPTIONS,
buildGitProviderSetupUrl,
createGitProviderDraft,
getGitProviderCallbackUrl,
getGitProviderSummary,
getProviderMeta,
} from "@/app/git/components/providers/git-provider-shared";
interface GitProvidersPanelProps {
providers: GitProviderRecord[];
actionLoading: string | null;
savingProviderId: string | null;
onUpsertProvider: (provider: GitProviderDraft) => Promise<boolean>;
onVerifyProvider: (provider: GitProviderDraft) => Promise<void>;
onToggleProvider: (provider: GitProviderRecord) => Promise<void>;
onDeleteProvider: (provider: GitProviderRecord) => void;
}
const PAGE_SIZE = 6;
type GitProviderStatusFilter = "all" | "active" | "disabled";
type GitProviderTypeFilter = "all" | GitProviderType;
const PROVIDER_CARD_COPY: Record<
GitProviderType,
{
description: string;
iconUrl: string;
iconSize: number;
iconBackground: string;
borderColor: string;
background: string;
}
> = {
github: {
description: "Connect GitHub Apps and repositories",
iconUrl: "https://thesvg.org/icons/github/default.svg",
iconSize: 34,
iconBackground: "#ffffff",
borderColor: "rgba(148,163,184,0.42)",
background:
"linear-gradient(180deg, rgba(255,255,255,0.04), rgba(148,163,184,0.04))",
},
gitlab: {
description: "Connect GitLab instance or group",
iconUrl: "https://thesvg.org/icons/gitlab/default.svg",
iconSize: 36,
iconBackground: "rgba(249,115,22,0.08)",
borderColor: "rgba(249,115,22,0.38)",
background:
"linear-gradient(180deg, rgba(249,115,22,0.08), rgba(249,115,22,0.03))",
},
bitbucket: {
description: "Connect Bitbucket Cloud or Server",
iconUrl: "https://thesvg.org/icons/bitbucket/default.svg",
iconSize: 34,
iconBackground: "rgba(59,130,246,0.1)",
borderColor: "rgba(59,130,246,0.42)",
background:
"linear-gradient(180deg, rgba(59,130,246,0.08), rgba(59,130,246,0.03))",
},
gitea: {
description: "Connect Gitea instance (self-hosted)",
iconUrl: "https://thesvg.org/icons/gitea/default.svg",
iconSize: 34,
iconBackground: "rgba(34,197,94,0.1)",
borderColor: "rgba(34,197,94,0.42)",
background:
"linear-gradient(180deg, rgba(34,197,94,0.08), rgba(34,197,94,0.03))",
},
};
export default function GitProvidersPanel({
providers,
actionLoading,
savingProviderId,
onUpsertProvider,
onVerifyProvider,
onToggleProvider,
onDeleteProvider,
}: GitProvidersPanelProps) {
const [draft, setDraft] = useState<GitProviderDraft | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [githubDetailsOpen, setGithubDetailsOpen] = useState(false);
const [providerSelectorOpen, setProviderSelectorOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] =
useState<GitProviderStatusFilter>("all");
const [providerFilter, setProviderFilter] =
useState<GitProviderTypeFilter>("all");
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
const [actionMenuPosition, setActionMenuPosition] = useState<{
top: number;
left: number;
} | null>(null);
useEffect(() => {
const handleWindowClick = () => {
setOpenActionMenuId(null);
setActionMenuPosition(null);
};
window.addEventListener("click", handleWindowClick);
return () => window.removeEventListener("click", handleWindowClick);
}, []);
const filteredProviders = useMemo(() => {
const normalizedQuery = searchQuery.trim().toLowerCase();
return providers.filter((provider) => {
if (statusFilter === "active" && !provider.enabled) return false;
if (statusFilter === "disabled" && provider.enabled) return false;
if (providerFilter !== "all" && provider.provider !== providerFilter) {
return false;
}
if (!normalizedQuery) return true;
return [
provider.name,
provider.provider,
provider.appName,
provider.organizationName,
provider.namespace,
provider.accountUsername,
getGitProviderSummary(provider),
]
.filter(Boolean)
.some((value) => `${value}`.toLowerCase().includes(normalizedQuery));
});
}, [providers, providerFilter, searchQuery, statusFilter]);
const totalItems = filteredProviders.length;
const totalPages = Math.max(1, Math.ceil(totalItems / PAGE_SIZE));
const safePage = Math.min(currentPage, totalPages);
const startIndex = (safePage - 1) * PAGE_SIZE;
const pagedProviders = filteredProviders.slice(
startIndex,
startIndex + PAGE_SIZE,
);
const startItem = totalItems === 0 ? 0 : startIndex + 1;
const endItem = totalItems === 0 ? 0 : startIndex + pagedProviders.length;
const setupUrl = useMemo(
() => (draft ? buildGitProviderSetupUrl(draft) : ""),
[draft],
);
const callbackUrl = useMemo(
() => (draft ? getGitProviderCallbackUrl(draft.provider) : ""),
[draft],
);
const openCreateModal = (provider: GitProviderType) => {
setProviderSelectorOpen(false);
setEditingId(null);
setGithubDetailsOpen(false);
setDraft(createGitProviderDraft(provider));
};
const openEditModal = (provider: GitProviderRecord) => {
setEditingId(provider.id);
setGithubDetailsOpen(
provider.provider === "github" &&
Boolean(
provider.clientId ||
provider.appId ||
provider.appUrl ||
provider.installationUrl,
),
);
setDraft({
id: provider.id,
provider: provider.provider,
name: provider.name,
enabled: provider.enabled,
appName: provider.appName,
appId: provider.appId,
clientId: provider.clientId,
clientSecret: "",
hasClientSecret: provider.hasClientSecret,
webhookSecret: "",
hasWebhookSecret: provider.hasWebhookSecret,
appUrl: provider.appUrl,
installationUrl: provider.installationUrl,
providerUrl: provider.providerUrl,
internalUrl: provider.internalUrl,
accountUsername: provider.accountUsername,
accountEmail: provider.accountEmail,
namespace: provider.namespace,
organizationScoped: provider.organizationScoped,
organizationName: provider.organizationName,
});
};
const closeModal = () => {
setEditingId(null);
setGithubDetailsOpen(false);
setDraft(null);
};
const closeProviderSelector = () => {
setProviderSelectorOpen(false);
};
const handleActionMenuToggle = (
event: MouseEvent<HTMLButtonElement>,
providerId: string,
) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
const menuWidth = 176;
const menuHeight = 174;
const nextLeft = Math.min(
window.innerWidth - menuWidth - 12,
Math.max(12, rect.right - menuWidth + rect.width),
);
const nextTop = Math.min(
window.innerHeight - menuHeight - 12,
rect.bottom + 6,
);
setActionMenuPosition({
top: Math.max(12, nextTop),
left: nextLeft,
});
setOpenActionMenuId((current) =>
current === providerId ? null : providerId,
);
};
const saveDraft = async () => {
if (!draft) return;
const success = await onUpsertProvider(draft);
if (success) {
closeModal();
}
};
const verifyDraft = async () => {
if (!draft) return;
await onVerifyProvider(draft);
};
const updateDraft = <K extends keyof GitProviderDraft>(
key: K,
value: GitProviderDraft[K],
) => {
setDraft((current) => (current ? { ...current, [key]: value } : current));
};
const modalTitle = draft
? `${getProviderMeta(draft.provider).label} Provider`
: "Git Provider";
const hideGithubFooterActions =
draft?.provider === "github" && !githubDetailsOpen;
const formatProviderCreatedAt = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return { date: value, time: "" };
}
return {
date: date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
year: "numeric",
}),
time: date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
}),
};
};
const isProviderIncomplete = (provider: GitProviderRecord) => {
switch (provider.provider) {
case "github":
return !provider.clientId || !provider.hasClientSecret;
case "gitlab":
return (
!provider.providerUrl ||
!provider.clientId ||
!provider.hasClientSecret
);
case "bitbucket":
return !provider.accountUsername || !provider.hasClientSecret;
case "gitea":
default:
return (
!provider.providerUrl ||
!provider.clientId ||
!provider.hasClientSecret
);
}
};
const getPrimaryProviderLink = (provider: GitProviderRecord) =>
provider.appUrl ||
provider.installationUrl ||
buildGitProviderSetupUrl({
...createGitProviderDraft(provider.provider),
...provider,
hasClientSecret: provider.hasClientSecret,
hasWebhookSecret: provider.hasWebhookSecret,
});
const getProviderDisplayName = (provider: GitProviderRecord) => {
if (provider.provider === "github")
return provider.appName || provider.name;
if (provider.provider === "bitbucket") {
return provider.name || provider.appName || "Bitbucket Provider";
}
return (
provider.name ||
provider.appName ||
getProviderMeta(provider.provider).label
);
};
const getProviderSubtitle = (provider: GitProviderRecord) =>
provider.appName ||
provider.providerUrl ||
provider.accountEmail ||
getGitProviderSummary(provider);
const getProviderTypeLabel = (provider: GitProviderRecord) => {
if (provider.provider === "github") return "GitHub App";
return getProviderMeta(provider.provider).label;
};
const getProviderScopeLabel = (provider: GitProviderRecord) => {
if (provider.organizationScoped) {
return (
provider.organizationName ||
provider.namespace ||
provider.accountUsername ||
"Organization"
);
}
return provider.namespace || "Global";
};
const getProviderTypeTone = (provider: GitProviderType) => {
switch (provider) {
case "github":
case "bitbucket":
return {
color: "#2563eb",
background: "rgba(37,99,235,0.12)",
border: "rgba(37,99,235,0.16)",
};
case "gitlab":
return {
color: "#ea580c",
background: "rgba(249,115,22,0.13)",
border: "rgba(249,115,22,0.18)",
};
case "gitea":
default:
return {
color: "#16a34a",
background: "rgba(34,197,94,0.14)",
border: "rgba(34,197,94,0.18)",
};
}
};
const renderProviderSelectionRow = () => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: 12,
}}
>
{PROVIDER_OPTIONS.map((option) => {
const card = PROVIDER_CARD_COPY[option.value];
return (
<button
key={option.value}
type="button"
onClick={() => openCreateModal(option.value)}
style={{
display: "grid",
gridTemplateColumns: "54px minmax(0, 1fr) 18px",
alignItems: "center",
gap: 14,
minHeight: 120,
padding: 16,
borderRadius: 12,
border: "1px solid rgba(59,130,246,0.24)",
background: "rgba(59,130,246,0.08)",
color: "var(--text-primary)",
textAlign: "left",
cursor: "pointer",
}}
>
<span
aria-hidden="true"
style={{
width: 50,
height: 50,
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: card.iconBackground,
overflow: "hidden",
}}
>
<img
src={card.iconUrl}
alt=""
width={card.iconSize}
height={card.iconSize}
style={{
display: "block",
objectFit: "contain",
}}
/>
</span>
<span style={{ minWidth: 0 }}>
<strong
style={{
display: "block",
fontSize: 14,
lineHeight: 1.25,
color: "var(--text-primary)",
}}
>
{option.label}
</strong>
<span
style={{
display: "block",
fontSize: 12,
lineHeight: 1.5,
color: "var(--text-secondary)",
marginTop: 8,
}}
>
{card.description}
</span>
</span>
<ChevronRight
size={18}
aria-hidden="true"
style={{ color: "var(--text-secondary)" }}
/>
</button>
);
})}
</div>
);
const renderProviderSelectorModal = () => (
<div className="modal-overlay" onClick={closeProviderSelector}>
<div
className="modal animate-slide-in"
onClick={(event) => event.stopPropagation()}
style={{
width: "100%",
maxWidth: 640,
maxHeight: "90vh",
padding: 28,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
gap: 12,
}}
>
<div>
<h3
style={{
color: "var(--text-primary)",
fontWeight: 700,
fontSize: 16,
}}
>
Add Git Provider
</h3>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 4,
}}
>
Select a Git provider to connect with Doktainer for authentication
and repository access.
</p>
</div>
<button
type="button"
onClick={closeProviderSelector}
aria-label="Close modal"
title="Close"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
marginTop: -8,
marginRight: -8,
width: 28,
height: 28,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={18} />
</button>
</div>
{renderProviderSelectionRow()}
</div>
</div>
);
const renderSharedCredentialFooter = () => (
<section
style={{
display: "grid",
gap: 8,
padding: 16,
borderRadius: 14,
border: "1px solid rgba(34,197,94,0.2)",
background: "rgba(34,197,94,0.06)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ShieldCheck size={16} style={{ color: "#22c55e" }} />
<strong style={{ fontSize: 13, color: "var(--text-primary)" }}>
Recorded by Doktainer
</strong>
</div>
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
Save important credentials and metadata here so Doktainer can use the
same configuration for subsequent Git flows without having to start the
setup from scratch.
</div>
</section>
);
const renderGithubFields = () => (
<GithubProviderModal
draft={draft!}
setupUrl={setupUrl}
callbackUrl={callbackUrl}
updateDraft={updateDraft}
detailsOpen={githubDetailsOpen}
setDetailsOpen={setGithubDetailsOpen}
/>
);
const renderGitlabFields = () => (
<GitlabProviderModal
draft={draft!}
setupUrl={setupUrl}
callbackUrl={callbackUrl}
updateDraft={updateDraft}
/>
);
const renderBitbucketFields = () => (
<BitbucketProviderModal
draft={draft!}
setupUrl={setupUrl}
callbackUrl={callbackUrl}
updateDraft={updateDraft}
/>
);
const renderGiteaFields = () => (
<GiteaProviderModal
draft={draft!}
setupUrl={setupUrl}
callbackUrl={callbackUrl}
updateDraft={updateDraft}
/>
);
const renderProviderModalBody = () => {
if (!draft) return null;
switch (draft.provider) {
case "github":
return renderGithubFields();
case "gitlab":
return renderGitlabFields();
case "bitbucket":
return renderBitbucketFields();
case "gitea":
default:
return renderGiteaFields();
}
};
return (
<>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div className="card" style={{ overflow: "visible" }}>
<div
style={{
padding: 20,
display: "flex",
alignItems: "center",
gap: 16,
flexWrap: "wrap",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
width: 42,
height: 42,
borderRadius: 8,
background:
"linear-gradient(135deg, rgba(37,99,235,0.16), rgba(37,99,235,0.06))",
border: "1px solid rgba(37,99,235,0.16)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<GitBranch size={18} style={{ color: "#2563eb" }} />
</div>
<div>
<h2
style={{
fontSize: 20,
fontWeight: 700,
color: "var(--text-primary)",
lineHeight: 1.2,
}}
>
Git Providers
</h2>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 4,
}}
>
Manage Git providers used by Doktainer for authentication and
repository access.
</p>
</div>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => setProviderSelectorOpen(true)}
style={{
padding: "5px 14px",
fontSize: 13,
minHeight: 35,
marginLeft: "auto",
}}
>
<Plus size={16} />
Add Provider
</button>
</div>
</div>
{providers.length === 0 ? (
<div className="card" style={{ padding: 20 }}>
<div
style={{
border: "1px solid var(--border)",
borderRadius: 18,
padding: "52px 24px",
textAlign: "center",
background: "rgba(255,255,255,0.02)",
}}
>
<GitBranch
size={32}
style={{
color: "var(--text-muted)",
marginBottom: 16,
textAlign: "center",
margin: "0 auto 12px",
}}
/>
<p
style={{
fontSize: 16,
fontWeight: 600,
color: "var(--text-primary)",
}}
>
Create your first Git Provider
</p>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 6,
}}
>
Start with GitHub App setup, then record the credentials in
Doktainer for this organization.
</p>
</div>
</div>
) : (
<div className="card" style={{ overflow: "visible" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
padding: "16px 18px",
borderBottom: "1px solid var(--border)",
}}
>
<label
style={{
position: "relative",
flex: "1 1 260px",
// maxWidth: 300,
}}
>
<Search
size={16}
style={{
position: "absolute",
left: 12,
top: "50%",
transform: "translateY(-50%)",
color: "var(--text-secondary)",
pointerEvents: "none",
}}
/>
<input
className="input"
value={searchQuery}
onChange={(event) => {
setSearchQuery(event.target.value);
setCurrentPage(1);
}}
placeholder="Search provider..."
style={{ paddingLeft: 38, minHeight: 30 }}
/>
</label>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 12,
flexWrap: "wrap",
marginLeft: "auto",
}}
>
<div
style={{
display: "flex",
border: "1px solid var(--border)",
borderRadius: 8,
overflow: "hidden",
background: "var(--bg-input)",
}}
>
{[
["all", "All"],
["active", "Active"],
["disabled", "Disabled"],
].map(([value, label]) => {
const active = statusFilter === value;
return (
<button
key={value}
type="button"
onClick={() => {
setStatusFilter(value as GitProviderStatusFilter);
setCurrentPage(1);
}}
style={{
minHeight: 35,
padding: "0 18px",
border: "none",
borderRight:
value === "disabled"
? "none"
: "1px solid var(--border)",
background: active
? "rgba(37,99,235,0.14)"
: "transparent",
color: active ? "#2563eb" : "var(--text-secondary)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
}}
>
{label}
</button>
);
})}
</div>
<select
className="input"
value={providerFilter}
onChange={(event) => {
setProviderFilter(
event.target.value as GitProviderTypeFilter,
);
setCurrentPage(1);
}}
style={{
width: 160,
minHeight: 30,
cursor: "pointer",
}}
>
<option value="all">Provider: All</option>
{PROVIDER_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
<div style={{ overflowX: "auto" }}>
<table className="data-table" style={{ minWidth: 900 }}>
<thead>
<tr>
<th>Provider</th>
<th>Type</th>
<th>Scope</th>
<th>Status</th>
<th>Created</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{pagedProviders.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: "center" }}>
No providers match the current filters.
</td>
</tr>
) : (
pagedProviders.map((provider) => {
const card = PROVIDER_CARD_COPY[provider.provider];
const tone = getProviderTypeTone(provider.provider);
const createdAt = formatProviderCreatedAt(
provider.createdAt,
);
const isSaving =
savingProviderId === provider.id ||
savingProviderId === `toggle-${provider.id}` ||
savingProviderId === `delete-${provider.id}`;
const needsAction = isProviderIncomplete(provider);
const menuOpen = openActionMenuId === provider.id;
return (
<tr key={provider.id}>
<td>
<div
style={{
display: "flex",
alignItems: "center",
gap: 12,
minWidth: 0,
}}
>
<span
aria-hidden="true"
style={{
width: 48,
height: 48,
borderRadius: 8,
border: "1px solid var(--border)",
background: card.iconBackground,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<img
src={card.iconUrl}
alt=""
width={32}
height={32}
style={{
display: "block",
objectFit: "contain",
}}
/>
</span>
<div style={{ minWidth: 0 }}>
<p
style={{
color: "var(--text-primary)",
fontWeight: 700,
fontSize: 13,
lineHeight: 1.25,
}}
>
{getProviderDisplayName(provider)}
</p>
<p
style={{
color: "var(--text-secondary)",
fontSize: 12,
marginTop: 3,
}}
>
{getProviderSubtitle(provider)}
</p>
{provider.appId ? (
<span
style={{
display: "inline-flex",
marginTop: 6,
padding: "2px 8px",
borderRadius: 6,
background: "rgba(148,163,184,0.16)",
color: "var(--text-secondary)",
fontSize: 11,
fontWeight: 600,
}}
>
App ID: {provider.appId}
</span>
) : null}
</div>
</div>
</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "4px 9px",
borderRadius: 7,
border: `1px solid ${tone.border}`,
background: tone.background,
color: tone.color,
fontSize: 12,
fontWeight: 600,
}}
>
{getProviderTypeLabel(provider)}
</span>
</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
color: "var(--text-secondary)",
fontSize: 12,
}}
>
{provider.organizationScoped ? (
<Users size={14} />
) : (
<Globe2 size={14} />
)}
{getProviderScopeLabel(provider)}
</span>
</td>
<td>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 10px",
borderRadius: 7,
background: provider.enabled
? "rgba(16,185,129,0.14)"
: "rgba(148,163,184,0.16)",
color: provider.enabled
? "#059669"
: "var(--text-secondary)",
fontSize: 12,
fontWeight: 700,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: 999,
background: provider.enabled
? "#059669"
: "var(--text-muted)",
}}
/>
{provider.enabled ? "Active" : "Disabled"}
</span>
{needsAction ? (
<span
style={{
display: "inline-flex",
marginLeft: 8,
padding: "4px 9px",
borderRadius: 7,
background: "rgba(245,158,11,0.12)",
color: "#d97706",
fontSize: 12,
fontWeight: 700,
}}
>
Action Required
</span>
) : null}
</td>
<td>
<div style={{ fontSize: 12 }}>
<div style={{ color: "var(--text-primary)" }}>
{createdAt.date}
</div>
<div
style={{
color: "var(--text-secondary)",
marginTop: 2,
}}
>
{createdAt.time}
</div>
</div>
</td>
<td style={{ textAlign: "right" }}>
<div
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 6,
}}
>
<button
type="button"
className="btn btn-ghost"
onClick={() => openEditModal(provider)}
style={{
padding: "4px 8px",
fontSize: 11,
}}
>
<Edit3 size={12} />
Edit
</button>
<button
type="button"
className="btn btn-ghost"
onClick={(event) =>
handleActionMenuToggle(event, provider.id)
}
disabled={isSaving}
aria-label={`More actions for ${provider.name}`}
title="More actions"
style={{
padding: "4px 8px",
fontSize: 11,
}}
>
{isSaving ? (
<Loader2 size={12} className="animate-spin" />
) : (
<MoreVertical size={12} />
)}
</button>
{menuOpen && actionMenuPosition ? (
<div
className="card"
onClick={(event) => event.stopPropagation()}
style={{
position: "fixed",
top: actionMenuPosition.top,
left: actionMenuPosition.left,
minWidth: 176,
padding: 6,
display: "flex",
flexDirection: "column",
gap: 4,
zIndex: 160,
boxShadow:
"0 18px 40px rgba(2, 6, 23, 0.42)",
}}
>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setOpenActionMenuId(null);
window.open(
getPrimaryProviderLink(provider),
"_blank",
"noopener,noreferrer",
);
}}
style={{
fontSize: 12,
justifyContent: "flex-start",
}}
>
<ExternalLink size={12} />
Open Provider
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setOpenActionMenuId(null);
void onToggleProvider(provider);
}}
style={{
fontSize: 12,
justifyContent: "flex-start",
}}
>
<Power size={12} />
{provider.enabled ? "Disable" : "Enable"}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => {
setOpenActionMenuId(null);
onDeleteProvider(provider);
}}
style={{
fontSize: 12,
justifyContent: "flex-start",
color: "#ef4444",
}}
>
<Trash2 size={12} />
Delete
</button>
</div>
) : null}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<TablePagination
currentPage={safePage}
totalPages={totalPages}
totalItems={totalItems}
startItem={startItem}
endItem={endItem}
itemLabel="providers"
onPageChange={(page) =>
setCurrentPage(Math.min(Math.max(page, 1), totalPages))
}
/>
</div>
)}
</div>
{providerSelectorOpen ? renderProviderSelectorModal() : null}
{draft ? (
<div className="modal-overlay" onClick={closeModal}>
<div
className="modal animate-slide-in"
onClick={(event) => event.stopPropagation()}
style={{
width:
draft.provider === "github"
? "min(640px, 100%)"
: "min(720px, 100%)",
maxWidth: 720,
maxHeight: "calc(100vh - 40px)",
overflowY: "auto",
padding: 24,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
alignItems: "flex-start",
marginBottom: 22,
}}
>
<div>
<h3
style={{
fontSize: 16,
fontWeight: 700,
color: "var(--text-primary)",
}}
>
{editingId ? `Update ${modalTitle}` : modalTitle}
</h3>
<p
style={{
fontSize: 12,
color: "var(--text-muted)",
marginTop: 6,
}}
>
Multi-configurations are still stored per organization, with a
structure that more closely mirrors the provider onboarding
flow.
</p>
</div>
<button
type="button"
onClick={closeModal}
aria-label="Close modal"
title="Close"
style={{
background: "none",
border: "none",
cursor: "pointer",
color: "var(--text-muted)",
padding: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={18} />
</button>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{renderProviderModalBody()}
{renderSharedCredentialFooter()}
</div>
{hideGithubFooterActions ? null : (
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: 12,
marginTop: 24,
flexWrap: "wrap",
}}
>
<button
type="button"
className="btn btn-ghost"
onClick={() => void verifyDraft()}
disabled={actionLoading === `verify-${draft.id ?? "draft"}`}
>
{actionLoading === `verify-${draft.id ?? "draft"}` ? (
<Loader2 size={14} className="animate-spin" />
) : (
<TestTube2 size={14} />
)}
Verify Config
</button>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<button
type="button"
className="btn btn-ghost"
onClick={closeModal}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
void saveDraft();
}}
disabled={savingProviderId === (draft.id || "new-provider")}
>
{savingProviderId === (draft.id || "new-provider") ? (
<Loader2 size={14} className="animate-spin" />
) : editingId ? (
<Pencil size={14} />
) : (
<Plus size={14} />
)}
{editingId ? "Save Changes" : "Save Provider"}
</button>
</div>
</div>
)}
</div>
</div>
) : null}
</>
);
}