"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; onVerifyProvider: (provider: GitProviderDraft) => Promise; onToggleProvider: (provider: GitProviderRecord) => Promise; 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(null); const [editingId, setEditingId] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [githubDetailsOpen, setGithubDetailsOpen] = useState(false); const [providerSelectorOpen, setProviderSelectorOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [providerFilter, setProviderFilter] = useState("all"); const [openActionMenuId, setOpenActionMenuId] = useState(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, 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 = ( 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 = () => (
{PROVIDER_OPTIONS.map((option) => { const card = PROVIDER_CARD_COPY[option.value]; return ( ); })}
); const renderProviderSelectorModal = () => (
event.stopPropagation()} style={{ width: "100%", maxWidth: 640, maxHeight: "90vh", padding: 28, }} >

Add Git Provider

Select a Git provider to connect with Doktainer for authentication and repository access.

{renderProviderSelectionRow()}
); const renderSharedCredentialFooter = () => (
Recorded by Doktainer
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.
); const renderGithubFields = () => ( ); const renderGitlabFields = () => ( ); const renderBitbucketFields = () => ( ); const renderGiteaFields = () => ( ); 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 ( <>

Git Providers

Manage Git providers used by Doktainer for authentication and repository access.

{providers.length === 0 ? (

Create your first Git Provider

Start with GitHub App setup, then record the credentials in Doktainer for this organization.

) : (
{[ ["all", "All"], ["active", "Active"], ["disabled", "Disabled"], ].map(([value, label]) => { const active = statusFilter === value; return ( ); })}
{pagedProviders.length === 0 ? ( ) : ( 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 ( ); }) )}
Provider Type Scope Status Created Actions
No providers match the current filters.

{getProviderDisplayName(provider)}

{getProviderSubtitle(provider)}

{provider.appId ? ( App ID: {provider.appId} ) : null}
{getProviderTypeLabel(provider)} {provider.organizationScoped ? ( ) : ( )} {getProviderScopeLabel(provider)} {provider.enabled ? "Active" : "Disabled"} {needsAction ? ( Action Required ) : null}
{createdAt.date}
{createdAt.time}
{menuOpen && actionMenuPosition ? (
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)", }} >
) : null}
setCurrentPage(Math.min(Math.max(page, 1), totalPages)) } />
)}
{providerSelectorOpen ? renderProviderSelectorModal() : null} {draft ? (
event.stopPropagation()} style={{ width: draft.provider === "github" ? "min(640px, 100%)" : "min(720px, 100%)", maxWidth: 720, maxHeight: "calc(100vh - 40px)", overflowY: "auto", padding: 24, }} >

{editingId ? `Update ${modalTitle}` : modalTitle}

Multi-configurations are still stored per organization, with a structure that more closely mirrors the provider onboarding flow.

{renderProviderModalBody()} {renderSharedCredentialFooter()}
{hideGithubFooterActions ? null : (
)}
) : null} ); }