import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Combobox } from '@/components/ui/combobox'; import { ConfirmModal } from '@/components/ui/modal'; import { toast } from '@/components/ui/toast-store'; import { apiFetch } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; import { CapabilityGate } from './CapabilityGate'; import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock, Zap } from 'lucide-react'; import { SettingsPrimaryButton } from './settings/SettingsActions'; import { SettingsCallout } from './settings/SettingsCallout'; import { useMastheadStats } from './settings/MastheadStatsContext'; type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom'; interface RegistryItem { id: number; name: string; url: string; type: RegistryType; username: string; has_secret: boolean; aws_region: string | null; created_at: number; updated_at: number; } interface ApiError { error?: string; message?: string; data?: { error?: string }; } const TYPE_OPTIONS: { value: RegistryType; label: string }[] = [ { value: 'dockerhub', label: 'Docker Hub' }, { value: 'ghcr', label: 'GitHub Container Registry (GHCR)' }, { value: 'ecr', label: 'AWS Elastic Container Registry (ECR)' }, { value: 'custom', label: 'Custom / Self-hosted' }, ]; const TYPE_LABELS: Record = { dockerhub: 'Docker Hub', ghcr: 'GitHub (GHCR)', ecr: 'AWS ECR', custom: 'Custom', }; const TYPE_BADGE_VARIANT: Record = { dockerhub: 'default', ghcr: 'secondary', ecr: 'secondary', custom: 'outline', }; const TYPE_URL_DEFAULTS: Record = { dockerhub: 'https://index.docker.io/v1/', ghcr: 'ghcr.io', ecr: '', custom: '', }; const TYPE_USERNAME_HINT: Record = { dockerhub: 'Docker Hub username', ghcr: 'GitHub username', ecr: 'AWS Access Key ID', custom: 'Username', }; const TYPE_SECRET_HINT: Record = { dockerhub: 'Access token or password', ghcr: 'Personal access token (PAT)', ecr: 'AWS Secret Access Key', custom: 'Password or token', }; /** Defensive toast chain per CLAUDE.md Directive 6. */ function toastError(e: unknown, fallback: string): void { const err = e as ApiError | undefined; toast.error(err?.message || err?.error || err?.data?.error || fallback); } function formatDate(ts: number): string { return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } export function RegistriesSection() { // Docker Hub, GHCR, and custom registry credentials are free; AWS ECR // (short-lived token refresh) stays paid, so the ECR type is gated. const { isPaid } = useLicense(); const typeOptions = isPaid ? TYPE_OPTIONS : TYPE_OPTIONS.filter(o => o.value !== 'ecr'); const [registries, setRegistries] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [testingId, setTestingId] = useState(null); const [testingForm, setTestingForm] = useState(false); const [deleteRegistry, setDeleteRegistry] = useState(null); const [formName, setFormName] = useState(''); const [formUrl, setFormUrl] = useState(''); const [formType, setFormType] = useState('dockerhub'); const [formUsername, setFormUsername] = useState(''); const [formSecret, setFormSecret] = useState(''); const [formAwsRegion, setFormAwsRegion] = useState(''); const fetchRegistries = async () => { try { const res = await apiFetch('/registries', { localOnly: true }); if (res.ok) { setRegistries(await res.json()); } } catch { toast.error('Failed to load registries.'); } finally { setLoading(false); } }; // eslint-disable-next-line react-hooks/set-state-in-effect useEffect(() => { fetchRegistries(); }, []); useMastheadStats( loading ? null : [ { label: 'REGISTRIES', value: `${registries.length}` }, ], ); const resetForm = () => { setFormName(''); setFormUrl(''); setFormType('dockerhub'); setFormUsername(''); setFormSecret(''); setFormAwsRegion(''); setEditingId(null); setShowForm(false); }; const handleTypeChange = (type: RegistryType) => { setFormType(type); if (!editingId) { setFormUrl(TYPE_URL_DEFAULTS[type]); } }; const startEdit = (reg: RegistryItem) => { setEditingId(reg.id); setFormName(reg.name); setFormUrl(reg.url); setFormType(reg.type); setFormUsername(reg.username); setFormSecret(''); setFormAwsRegion(reg.aws_region ?? ''); setShowForm(true); }; const validateForm = (): boolean => { if (!formName.trim()) { toast.error('Name is required.'); return false; } if (!formUrl.trim()) { toast.error('URL is required.'); return false; } if (!formUsername.trim()) { toast.error('Username is required.'); return false; } if (!editingId && !formSecret.trim()) { toast.error('Secret/token is required.'); return false; } if (formType === 'ecr' && !formAwsRegion.trim()) { toast.error('AWS region is required for ECR.'); return false; } return true; }; const handleTestForm = async () => { // Stateless test requires a secret; on edit, user must re-enter it. if (!formUrl.trim() || !formUsername.trim() || !formSecret.trim()) { toast.error('Fill URL, username, and secret to test.'); return; } if (formType === 'ecr' && !formAwsRegion.trim()) { toast.error('AWS region is required for ECR.'); return; } setTestingForm(true); try { const res = await apiFetch('/registries/test', { method: 'POST', localOnly: true, body: JSON.stringify({ type: formType, url: formUrl.trim(), username: formUsername.trim(), secret: formSecret.trim(), aws_region: formType === 'ecr' ? formAwsRegion.trim() : null, }), }); if (res.ok) { const data = await res.json(); if (data.success) { toast.success('Connection successful.'); } else { toast.error(data.error || 'Connection failed.'); } } else { const err = await res.json().catch(() => ({})); toastError(err, 'Test failed.'); } } catch (e) { toastError(e, 'Network error.'); } finally { setTestingForm(false); } }; const handleSave = async () => { if (!validateForm()) return; setSaving(true); try { const body: Record = { name: formName.trim(), url: formUrl.trim(), type: formType, username: formUsername.trim(), aws_region: formType === 'ecr' ? formAwsRegion.trim() : null, }; if (formSecret.trim()) body.secret = formSecret.trim(); const url = editingId ? `/registries/${editingId}` : '/registries'; const method = editingId ? 'PUT' : 'POST'; const res = await apiFetch(url, { method, localOnly: true, body: JSON.stringify(body), }); if (res.ok) { toast.success(editingId ? 'Registry updated.' : 'Registry added.'); resetForm(); fetchRegistries(); } else { const err = await res.json().catch(() => ({})); toastError(err, 'Failed to save registry.'); } } catch (e) { toastError(e, 'Network error.'); } finally { setSaving(false); } }; const handleDelete = async (id: number) => { try { const res = await apiFetch(`/registries/${id}`, { method: 'DELETE', localOnly: true }); if (res.ok) { toast.success('Registry deleted.'); fetchRegistries(); } else { const err = await res.json().catch(() => ({})); toastError(err, 'Failed to delete registry.'); } } catch (e) { toastError(e, 'Network error.'); } }; const handleTest = async (id: number) => { setTestingId(id); try { const res = await apiFetch(`/registries/${id}/test`, { method: 'POST', localOnly: true }); if (res.ok) { const data = await res.json(); if (data.success) { toast.success('Connection successful.'); } else { toast.error(data.error || 'Connection failed.'); } } else { const err = await res.json().catch(() => ({})); toastError(err, 'Test failed.'); } } catch (e) { toastError(e, 'Network error.'); } finally { setTestingId(null); } }; return (
{ resetForm(); setShowForm(true); }}> Add registry
{/* Create / Edit form */} {showForm && (
handleTypeChange(v as RegistryType)} placeholder="Select a registry type" searchPlaceholder="Search types..." /> {!isPaid && (

AWS ECR requires Admiral.

)}
setFormName(e.target.value)} maxLength={100} />
setFormUrl(e.target.value)} maxLength={500} disabled={formType === 'dockerhub'} />
setFormUsername(e.target.value)} />
setFormSecret(e.target.value)} />
{formType === 'ecr' && (
setFormAwsRegion(e.target.value)} />
)}
{saving ? ( <>Saving ) : editingId ? 'Update' : 'Add'}
)} {/* Loading state */} {loading && (
)} {/* Empty state */} {!loading && registries.length === 0 && !showForm && ( } title="No private registries configured" subtitle="Add one to pull images from Docker Hub orgs, GHCR, ECR, or self-hosted registries." /> )} {/* Registry list */} {!loading && registries.map(reg => (
{reg.name} {TYPE_LABELS[reg.type]}
{reg.url} {reg.username} {reg.has_secret ? ( <> Secret stored ) : ( <> No secret )} {reg.aws_region && Region: {reg.aws_region}} {formatDate(reg.created_at)}
))} { if (!open) setDeleteRegistry(null); }} variant="destructive" kicker="REGISTRY · DELETE · IRREVERSIBLE" title="Delete registry" confirmLabel="Delete" onConfirm={() => { if (deleteRegistry) { const id = deleteRegistry.id; setDeleteRegistry(null); handleDelete(id); } }} >

Removes {deleteRegistry?.name} and its stored credentials. Stacks using images from this registry will fail to pull until credentials are re-added.

); }