mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
2298f470bd
Private registry credentials are no longer paid-only. Community admins can add and manage Docker Hub, GHCR, and custom/self-hosted registry credentials, stored locally on the node. AWS ECR (short-lived token refresh, AWS region) stays on the paid tier. Backend gates ECR per-type via a single helper applied at create, update (using the effective type so an existing row cannot be switched to ECR), the per-id connection test (gated on the stored type), and the stateless test. The admin role and API-token-scope rejection are unchanged on every route. The frontend drops the blanket paywall on the Registries section, filters ECR out of the type selector for Community, and states the ECR requirement inline.
485 lines
21 KiB
TypeScript
485 lines
21 KiB
TypeScript
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<RegistryType, string> = {
|
|
dockerhub: 'Docker Hub',
|
|
ghcr: 'GitHub (GHCR)',
|
|
ecr: 'AWS ECR',
|
|
custom: 'Custom',
|
|
};
|
|
|
|
const TYPE_BADGE_VARIANT: Record<RegistryType, 'default' | 'secondary' | 'outline'> = {
|
|
dockerhub: 'default',
|
|
ghcr: 'secondary',
|
|
ecr: 'secondary',
|
|
custom: 'outline',
|
|
};
|
|
|
|
const TYPE_URL_DEFAULTS: Record<RegistryType, string> = {
|
|
dockerhub: 'https://index.docker.io/v1/',
|
|
ghcr: 'ghcr.io',
|
|
ecr: '',
|
|
custom: '',
|
|
};
|
|
|
|
const TYPE_USERNAME_HINT: Record<RegistryType, string> = {
|
|
dockerhub: 'Docker Hub username',
|
|
ghcr: 'GitHub username',
|
|
ecr: 'AWS Access Key ID',
|
|
custom: 'Username',
|
|
};
|
|
|
|
const TYPE_SECRET_HINT: Record<RegistryType, string> = {
|
|
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<RegistryItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [testingId, setTestingId] = useState<number | null>(null);
|
|
const [testingForm, setTestingForm] = useState(false);
|
|
const [deleteRegistry, setDeleteRegistry] = useState<RegistryItem | null>(null);
|
|
|
|
const [formName, setFormName] = useState('');
|
|
const [formUrl, setFormUrl] = useState('');
|
|
const [formType, setFormType] = useState<RegistryType>('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<string, unknown> = {
|
|
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 (
|
|
<CapabilityGate capability="registries" featureName="Private Registries">
|
|
<div className="space-y-6">
|
|
<div className="flex justify-end">
|
|
<SettingsPrimaryButton size="sm" onClick={() => { resetForm(); setShowForm(true); }}>
|
|
<Plus className="w-4 h-4" strokeWidth={1.5} /> Add registry
|
|
</SettingsPrimaryButton>
|
|
</div>
|
|
|
|
{/* Create / Edit form */}
|
|
{showForm && (
|
|
<div className="space-y-4 p-4 rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
|
<div className="space-y-2">
|
|
<Label>Registry Type</Label>
|
|
<Combobox
|
|
options={typeOptions}
|
|
value={formType}
|
|
onValueChange={(v) => handleTypeChange(v as RegistryType)}
|
|
placeholder="Select a registry type"
|
|
searchPlaceholder="Search types..."
|
|
/>
|
|
{!isPaid && (
|
|
<p className="text-xs text-stat-subtitle">AWS ECR requires Admiral.</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Name</Label>
|
|
<Input
|
|
placeholder="My private registry"
|
|
value={formName}
|
|
onChange={e => setFormName(e.target.value)}
|
|
maxLength={100}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Registry URL</Label>
|
|
<Input
|
|
placeholder={formType === 'ecr' ? '123456789.dkr.ecr.us-east-1.amazonaws.com' : 'registry.example.com'}
|
|
value={formUrl}
|
|
onChange={e => setFormUrl(e.target.value)}
|
|
maxLength={500}
|
|
disabled={formType === 'dockerhub'}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>{formType === 'ecr' ? 'AWS Access Key ID' : 'Username'}</Label>
|
|
<Input
|
|
placeholder={TYPE_USERNAME_HINT[formType]}
|
|
value={formUsername}
|
|
onChange={e => setFormUsername(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>{formType === 'ecr' ? 'AWS Secret Access Key' : 'Secret / Token'}</Label>
|
|
<Input
|
|
type="password"
|
|
placeholder={editingId ? '(leave blank to keep current)' : TYPE_SECRET_HINT[formType]}
|
|
value={formSecret}
|
|
onChange={e => setFormSecret(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{formType === 'ecr' && (
|
|
<div className="space-y-2">
|
|
<Label>AWS Region</Label>
|
|
<Input
|
|
placeholder="us-east-1"
|
|
value={formAwsRegion}
|
|
onChange={e => setFormAwsRegion(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between items-center gap-2 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleTestForm}
|
|
disabled={testingForm || saving}
|
|
>
|
|
{testingForm ? (
|
|
<><RefreshCw className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Testing...</>
|
|
) : (
|
|
<><Zap className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Test connection</>
|
|
)}
|
|
</Button>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={resetForm}>Cancel</Button>
|
|
<SettingsPrimaryButton size="sm" onClick={handleSave} disabled={saving}>
|
|
{saving ? (
|
|
<><RefreshCw className="w-4 h-4 animate-spin" strokeWidth={1.5} />Saving</>
|
|
) : editingId ? 'Update' : 'Add'}
|
|
</SettingsPrimaryButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{loading && (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-20 w-full rounded-lg" />
|
|
<Skeleton className="h-20 w-full rounded-lg" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{!loading && registries.length === 0 && !showForm && (
|
|
<SettingsCallout
|
|
icon={<Database className="h-4 w-4" strokeWidth={1.5} />}
|
|
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 => (
|
|
<div key={reg.id} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel transition-colors hover:border-t-card-border-hover p-4 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<Database className="w-4 h-4 text-stat-icon shrink-0" strokeWidth={1.5} />
|
|
<span className="font-medium text-sm truncate">{reg.name}</span>
|
|
<Badge variant={TYPE_BADGE_VARIANT[reg.type]} className="text-[10px] shrink-0">
|
|
{TYPE_LABELS[reg.type]}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleTest(reg.id)}
|
|
disabled={testingId === reg.id}
|
|
title="Test connection"
|
|
>
|
|
{testingId === reg.id ? (
|
|
<RefreshCw className="w-4 h-4 animate-spin" strokeWidth={1.5} />
|
|
) : (
|
|
<CheckCircle className="w-4 h-4" strokeWidth={1.5} />
|
|
)}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" onClick={() => startEdit(reg)} title="Edit">
|
|
<Pencil className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
|
|
title="Delete"
|
|
onClick={() => setDeleteRegistry(reg)}
|
|
>
|
|
<Trash2 className="w-4 h-4" strokeWidth={1.5} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-4 text-xs text-stat-subtitle">
|
|
<span className="font-mono truncate max-w-[200px]" title={reg.url}>{reg.url}</span>
|
|
<span>{reg.username}</span>
|
|
<span className="flex items-center gap-1">
|
|
{reg.has_secret ? (
|
|
<><CheckCircle className="w-3 h-3 text-success" strokeWidth={1.5} /> Secret stored</>
|
|
) : (
|
|
<><XCircle className="w-3 h-3 text-destructive" strokeWidth={1.5} /> No secret</>
|
|
)}
|
|
</span>
|
|
{reg.aws_region && <span>Region: {reg.aws_region}</span>}
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3 h-3" strokeWidth={1.5} />
|
|
{formatDate(reg.created_at)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<ConfirmModal
|
|
open={deleteRegistry !== null}
|
|
onOpenChange={(open) => { 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);
|
|
}
|
|
}}
|
|
>
|
|
<p className="text-sm text-stat-subtitle">
|
|
Removes <span className="font-medium text-stat-value">{deleteRegistry?.name}</span> and its stored credentials. Stacks using images from this registry will fail to pull until credentials are re-added.
|
|
</p>
|
|
</ConfirmModal>
|
|
</div>
|
|
</CapabilityGate>
|
|
);
|
|
}
|