diff --git a/src/app/(app)/(admin)/settings/sso/ci/page.tsx b/src/app/(app)/(admin)/settings/sso/ci/page.tsx new file mode 100644 index 00000000..16680bb8 --- /dev/null +++ b/src/app/(app)/(admin)/settings/sso/ci/page.tsx @@ -0,0 +1,1014 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { + Plus, + Pencil, + Trash2, + ToggleLeft, + ToggleRight, + Loader2, + GitBranch, + Cpu, + ChevronDown, + ChevronRight, + Filter, + ShieldCheck, +} from "lucide-react"; + +import { ciOidcApi } from "@/lib/api/ci-oidc"; +import { repositoriesApi } from "@/lib/api/repositories"; +import { mutationErrorToast } from "@/lib/error-utils"; +import type { + CiOidcProvider, + CiOidcIdentityMapping, + CiOidcProviderType, + ClaimFilters, + CreateCiOidcProviderRequest, + UpdateCiOidcProviderRequest, + CreateCiOidcMappingRequest, + UpdateCiOidcMappingRequest, +} from "@/types/ci-oidc"; +import type { Repository } from "@/types"; + +import { PageHeader } from "@/components/common/page-header"; +import { StatusBadge } from "@/components/common/status-badge"; +import { ConfirmDialog } from "@/components/common/confirm-dialog"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; + +// --------------------------------------------------------------------------- +// Provider type helpers +// --------------------------------------------------------------------------- + +const PROVIDER_TYPE_LABELS: Record = { + gitlab: "GitLab", + github: "GitHub Actions", + generic: "Generic OIDC", +}; + +const DEFAULT_ISSUER: Record = { + gitlab: "https://gitlab.com", + github: "https://token.actions.githubusercontent.com", + generic: "", +}; + +const CLAIM_PLACEHOLDER: Record = { + gitlab: `{\n "namespace_path": "my-org/my-group",\n "ref_protected": "true"\n}`, + github: `{\n "repository": "my-org/my-repo",\n "ref": "refs/heads/main"\n}`, + generic: `{\n "sub": "system:serviceaccount:prod"\n}`, +}; + +function ProviderTypeIcon({ type }: { type: string }) { + if (type === "gitlab" || type === "github") + return ; + return ; +} + +// --------------------------------------------------------------------------- +// Provider form +// --------------------------------------------------------------------------- + +const BLANK_PROVIDER_FORM = { + name: "", + provider_type: "generic" as CiOidcProviderType, + issuer_url: "", + audience: "artifact-keeper", +}; +type ProviderForm = typeof BLANK_PROVIDER_FORM; + +function providerFormFromRow(p: CiOidcProvider): ProviderForm { + return { + name: p.name, + provider_type: p.provider_type, + issuer_url: p.issuer_url, + audience: p.audience, + }; +} + +// --------------------------------------------------------------------------- +// Mapping form +// --------------------------------------------------------------------------- + +const BLANK_MAPPING_FORM = { + name: "", + priority: "100", + claim_filters_raw: "", + repo_scope_mode: "all" as "all" | "selected", + selected_repo_ids: [] as string[], + is_enabled: true, +}; +type MappingForm = typeof BLANK_MAPPING_FORM; + +function mappingFormFromRow(m: CiOidcIdentityMapping): MappingForm { + return { + name: m.name, + priority: String(m.priority), + claim_filters_raw: JSON.stringify(m.claim_filters, null, 2), + repo_scope_mode: m.allowed_repo_ids === null ? "all" : "selected", + selected_repo_ids: m.allowed_repo_ids ?? [], + is_enabled: m.is_enabled, + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseClaimFilters(raw: string): ClaimFilters | undefined { + const t = raw.trim(); + if (!t) return {}; + try { + return JSON.parse(t) as ClaimFilters; + } catch { + return undefined; + } +} + +function claimFilterSummary(filters: ClaimFilters): string { + const keys = Object.keys(filters); + if (!keys.length) return "No filters (any JWT accepted)"; + return keys + .map((k) => { + const v = filters[k]; + return Array.isArray(v) ? `${k} ∈ [${v.join(", ")}]` : `${k} = ${v}`; + }) + .join(", "); +} + +function repoScopeSummary( + allowedRepoIds: CiOidcIdentityMapping["allowed_repo_ids"], +): string { + if (allowedRepoIds === null) return "All repositories"; + if (allowedRepoIds.length === 0) return "No repositories"; + if (allowedRepoIds.length === 1) return "1 repository"; + return `${allowedRepoIds.length} repositories`; +} + +// --------------------------------------------------------------------------- +// Mappings sub-panel +// --------------------------------------------------------------------------- + +interface MappingsPanelProps { + provider: CiOidcProvider; +} + +function MappingsPanel({ provider }: MappingsPanelProps) { + const queryClient = useQueryClient(); + const qKey = ["ci-oidc-mappings", provider.id]; + + const { data: mappings, isLoading } = useQuery({ + queryKey: qKey, + queryFn: () => ciOidcApi.listMappings(provider.id), + }); + + const { data: repositoriesPage, isLoading: isLoadingRepositories } = useQuery({ + queryKey: ["repositories", "ci-oidc-picker"], + queryFn: () => repositoriesApi.list({ page: 1, per_page: 500 }), + }); + const repositories = useMemo( + () => [...(repositoriesPage?.items ?? [])].sort((a, b) => a.key.localeCompare(b.key)), + [repositoriesPage?.items], + ); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editTarget, setEditTarget] = useState( + null, + ); + const [deleteTarget, setDeleteTarget] = + useState(null); + const [form, setForm] = useState(BLANK_MAPPING_FORM); + const [filtersError, setFiltersError] = useState(null); + + function invalidate() { + queryClient.invalidateQueries({ queryKey: qKey }); + queryClient.invalidateQueries({ queryKey: ["ci-oidc"] }); + } + + const createMutation = useMutation({ + mutationFn: (req: CreateCiOidcMappingRequest) => + ciOidcApi.createMapping(provider.id, req), + onSuccess: () => { + invalidate(); + toast.success("Mapping created"); + closeDialog(); + }, + onError: mutationErrorToast("Failed to create mapping"), + }); + + const updateMutation = useMutation({ + mutationFn: ({ + id, + req, + }: { + id: string; + req: UpdateCiOidcMappingRequest; + }) => ciOidcApi.updateMapping(provider.id, id, req), + onSuccess: () => { + invalidate(); + toast.success("Mapping updated"); + closeDialog(); + }, + onError: mutationErrorToast("Failed to update mapping"), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => ciOidcApi.deleteMapping(provider.id, id), + onSuccess: () => { + invalidate(); + toast.success("Mapping deleted"); + setDeleteTarget(null); + }, + onError: mutationErrorToast("Failed to delete mapping"), + }); + + const toggleMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + ciOidcApi.toggleMapping(provider.id, id, { enabled }), + onSuccess: () => invalidate(), + onError: mutationErrorToast("Failed to toggle mapping"), + }); + + function closeDialog() { + setDialogOpen(false); + setEditTarget(null); + setForm(BLANK_MAPPING_FORM); + setFiltersError(null); + } + + function openCreate() { + setEditTarget(null); + setForm({ + ...BLANK_MAPPING_FORM, + claim_filters_raw: CLAIM_PLACEHOLDER[provider.provider_type] ?? "", + }); + setFiltersError(null); + setDialogOpen(true); + } + + function openEdit(m: CiOidcIdentityMapping) { + setEditTarget(m); + setForm(mappingFormFromRow(m)); + setFiltersError(null); + setDialogOpen(true); + } + + function setField( + key: K, + value: MappingForm[K], + ) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function toggleRepoSelection(repoId: string, checked: boolean) { + setForm((prev) => { + if (checked) { + if (prev.selected_repo_ids.includes(repoId)) return prev; + return { + ...prev, + selected_repo_ids: [...prev.selected_repo_ids, repoId], + }; + } + return { + ...prev, + selected_repo_ids: prev.selected_repo_ids.filter((id) => id !== repoId), + }; + }); + } + + const selectedRepositories = useMemo(() => { + const byId = new Map( + repositories.map((repo) => [repo.id, repo]), + ); + return form.selected_repo_ids.map((id) => byId.get(id)).filter(Boolean) as Repository[]; + }, [form.selected_repo_ids, repositories]); + + function handleSubmit() { + setFiltersError(null); + const filters = parseClaimFilters(form.claim_filters_raw); + if (filters === undefined) { + setFiltersError("Invalid JSON — please fix claim_filters before saving."); + return; + } + const priority = parseInt(form.priority, 10); + if (isNaN(priority)) { + setFiltersError("Priority must be a number."); + return; + } + + const payload = { + name: form.name, + priority, + claim_filters: filters, + allowed_repo_ids: + form.repo_scope_mode === "all" ? null : form.selected_repo_ids, + is_enabled: form.is_enabled, + }; + + if (editTarget) { + updateMutation.mutate({ id: editTarget.id, req: payload }); + } else { + createMutation.mutate(payload as CreateCiOidcMappingRequest); + } + } + + const isSaving = createMutation.isPending || updateMutation.isPending; + + return ( +
+
+ + Identity Mappings + + +
+ + {isLoading ? ( +
+ + +
+ ) : mappings && mappings.length > 0 ? ( + + + + Priority + Name + Claim Filters + Repository Scope + Status + Actions + + + + {mappings.map((m) => ( + + + + {m.priority} + + + {m.name} + + {claimFilterSummary(m.claim_filters)} + + + {repoScopeSummary(m.allowed_repo_ids)} + + + + + +
+ + + +
+
+
+ ))} +
+
+ ) : ( +
+ + No identity mappings yet. Add one to allow pipelines to authenticate. +
+ )} + + {/* Mapping Create/Edit Dialog */} + !open && closeDialog()}> + + + + {editTarget ? "Edit Identity Mapping" : "Add Identity Mapping"} + + + Mappings are evaluated in priority order (lowest number first). + The first enabled mapping whose claim filters all match the CI JWT + wins. + + + +
+
+ + setField("name", e.target.value)} + /> +
+ +
+ + setField("priority", e.target.value)} + /> +

+ Lower = evaluated first. Use 10, 20, 30 … to leave room for + reordering. +

+
+ +
+ +