Publish curated Unraid-AppOps-Gateway source from ad0a390e431166c3198a2ac3bec010c2b44f2dea
This commit is contained in:
1 parent
29c1e3d5a1
commit
98d31477f9
35 files changed
+7264
-185
No files matched your search
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Database, LoaderCircle, RefreshCw, ShieldAlert } from 'lucide-react';
|
||||
import { appopsClient } from '@/api/appopsClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { formatOperatorError } from '@/lib/operatorError';
|
||||
import {
|
||||
buildEnrollmentStorageView,
|
||||
buildStorageRootSelection,
|
||||
canSaveStorageRootSelection,
|
||||
canRequestManagedStorageInitialization,
|
||||
normalizeManagedStoragePreview,
|
||||
normalizeManagedStorageActionOutcome,
|
||||
LEGACY_APPDATA_ROOT,
|
||||
MANAGED_APPDATA_ROOT,
|
||||
MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
MANAGED_STORAGE_TARGET,
|
||||
managedStorageBlockerLabel,
|
||||
managedStorageStatusLabel,
|
||||
rootReadinessLabel,
|
||||
} from '@/lib/enrollmentStorageView';
|
||||
import { useUserSession } from '@/hooks/useUserSession';
|
||||
import type { ActionPlan, EnrollmentCapabilities, EnrollmentTrustStatus, PreflightPreview, TrustedAppdataRoot } from '@/types/appops';
|
||||
|
||||
interface StorageSnapshot {
|
||||
trust: EnrollmentTrustStatus;
|
||||
capabilities: EnrollmentCapabilities;
|
||||
view: ReturnType<typeof buildEnrollmentStorageView>;
|
||||
preview: PreflightPreview | null;
|
||||
previewError: string | null;
|
||||
}
|
||||
|
||||
interface StorageDraft {
|
||||
root: TrustedAppdataRoot;
|
||||
backupAcknowledged: boolean;
|
||||
}
|
||||
|
||||
const labelForRoot = (root: TrustedAppdataRoot) => (
|
||||
root === LEGACY_APPDATA_ROOT ? 'Unraid appdata share' : 'Dedicated managed Docker-root volume'
|
||||
);
|
||||
|
||||
const isConflict = (error: unknown): boolean => (
|
||||
error instanceof Error && /Backend 409:/.test(error.message)
|
||||
);
|
||||
|
||||
export const ManagedStorageSettings = () => {
|
||||
const session = useUserSession();
|
||||
const isAdmin = session.ready && session.authenticated && session.user?.role === 'admin';
|
||||
const canManage = isAdmin && session.permissions.includes('settings.manage');
|
||||
const [snapshot, setSnapshot] = useState<StorageSnapshot | null>(null);
|
||||
const [draft, setDraft] = useState<StorageDraft>({ root: LEGACY_APPDATA_ROOT, backupAcknowledged: false });
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [conflict, setConflict] = useState('');
|
||||
const [saveNotice, setSaveNotice] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [initializing, setInitializing] = useState(false);
|
||||
const [actionNotice, setActionNotice] = useState<{ tone: 'status' | 'alert'; message: string } | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const snapshotRef = useRef<StorageSnapshot | null>(null);
|
||||
const draftRef = useRef(draft);
|
||||
|
||||
const updateDraft = (next: StorageDraft) => {
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
};
|
||||
|
||||
const load = useCallback(async (options: { preserveDraft?: boolean } = {}) => {
|
||||
if (!canManage) return;
|
||||
const sequence = ++requestSequence.current;
|
||||
setRefreshing(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const [trust, capabilities] = await Promise.all([
|
||||
appopsClient.getEnrollmentTrustStatus(),
|
||||
appopsClient.getEnrollmentCapabilities(),
|
||||
]);
|
||||
const view = buildEnrollmentStorageView(trust, capabilities);
|
||||
let preview: PreflightPreview | null = null;
|
||||
let previewError: string | null = null;
|
||||
if (view.managedStatus === 'initialization_required') {
|
||||
try {
|
||||
const rawPreview: unknown = await appopsClient.previewAction(MANAGED_STORAGE_INITIALIZE_ACTION, MANAGED_STORAGE_TARGET);
|
||||
preview = normalizeManagedStoragePreview(rawPreview);
|
||||
if (!preview) previewError = 'De policy-preview had geen geldig formaat; de actie blijft geblokkeerd.';
|
||||
} catch (error) {
|
||||
previewError = formatOperatorError(error, 'De policy-preview is niet beschikbaar.');
|
||||
}
|
||||
}
|
||||
if (sequence !== requestSequence.current) return;
|
||||
|
||||
const previous = snapshotRef.current;
|
||||
const currentDraft = draftRef.current;
|
||||
const draftChanged = previous !== null && (
|
||||
currentDraft.root !== previous.view.currentRoot ||
|
||||
currentDraft.backupAcknowledged !== previous.view.backupAcknowledged
|
||||
);
|
||||
const nextSnapshot = { trust, capabilities, view, preview, previewError };
|
||||
snapshotRef.current = nextSnapshot;
|
||||
setSnapshot(nextSnapshot);
|
||||
if (!options.preserveDraft && !draftChanged && view.currentRoot) {
|
||||
updateDraft({ root: view.currentRoot, backupAcknowledged: view.backupAcknowledged });
|
||||
}
|
||||
if (!options.preserveDraft) setConflict('');
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setLoadError(formatOperatorError(error, 'Opslaginstellingen konden niet worden geladen.'));
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setRefreshing(false);
|
||||
}
|
||||
}, [canManage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManage) void load();
|
||||
}, [canManage, load]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const view = snapshot?.view;
|
||||
const selectedDraft = draft.root;
|
||||
const draftIsManaged = selectedDraft === MANAGED_APPDATA_ROOT;
|
||||
const dirty = Boolean(view?.currentRoot && (
|
||||
draft.root !== view.currentRoot ||
|
||||
(draftIsManaged && draft.backupAcknowledged !== view.backupAcknowledged)
|
||||
));
|
||||
const requestPayload = buildStorageRootSelection(draft.root, draft.backupAcknowledged);
|
||||
const saveDisabled = !canSaveStorageRootSelection({
|
||||
canManage,
|
||||
configured: Boolean(snapshot?.view.configured),
|
||||
payloadAvailable: Boolean(requestPayload),
|
||||
dirty,
|
||||
loadError,
|
||||
refreshing,
|
||||
saving,
|
||||
initializing,
|
||||
});
|
||||
const initializationGate = {
|
||||
canManage,
|
||||
view,
|
||||
preview: snapshot?.preview,
|
||||
dirty,
|
||||
loadError,
|
||||
conflict,
|
||||
refreshing,
|
||||
saving,
|
||||
initializing,
|
||||
};
|
||||
const initializeAllowed = canRequestManagedStorageInitialization(initializationGate);
|
||||
|
||||
const save = async () => {
|
||||
if (!canManage || !requestPayload || saveDisabled) return;
|
||||
const sequence = ++requestSequence.current;
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setConflict('');
|
||||
setSaveNotice('');
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const updatedTrust = await appopsClient.updateEnrollmentStorageRoot(requestPayload);
|
||||
if (sequence !== requestSequence.current) return;
|
||||
const current = snapshotRef.current;
|
||||
if (current) {
|
||||
const updatedView = buildEnrollmentStorageView(updatedTrust, current.capabilities);
|
||||
const updatedSnapshot = { ...current, trust: updatedTrust, view: updatedView };
|
||||
snapshotRef.current = updatedSnapshot;
|
||||
setSnapshot(updatedSnapshot);
|
||||
if (updatedView.currentRoot) updateDraft({ root: updatedView.currentRoot, backupAcknowledged: updatedView.backupAcknowledged });
|
||||
}
|
||||
setSaveNotice('Storagevoorkeur opgeslagen. De controller-readiness wordt opnieuw gecontroleerd; bestaande targets blijven ongewijzigd.');
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
if (isConflict(error)) {
|
||||
setConflict('De trustconfiguratie is intussen gewijzigd. De actuele status wordt geladen; jouw keuze blijft als concept bewaard. Controleer de selectie en sla opnieuw op als die nog klopt.');
|
||||
await load({ preserveDraft: true });
|
||||
} else {
|
||||
setSaveError(formatOperatorError(error, 'De storagevoorkeur is niet opgeslagen. Jouw concept blijft behouden.'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
if (!snapshot?.preview || !view || !canRequestManagedStorageInitialization(initializationGate)) return;
|
||||
const preview = snapshot.preview;
|
||||
const plan: ActionPlan = {
|
||||
intent: MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
target: MANAGED_STORAGE_TARGET,
|
||||
source: 'manual',
|
||||
risk: preview.risk,
|
||||
policy: preview.policyDecision,
|
||||
reason: preview.auditPreview,
|
||||
plan: preview.preChecks,
|
||||
postChecks: preview.postChecks,
|
||||
};
|
||||
setInitializing(true);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const result: unknown = await appopsClient.executeAction(plan);
|
||||
setActionNotice(normalizeManagedStorageActionOutcome(result));
|
||||
await load({ preserveDraft: true });
|
||||
} catch (error) {
|
||||
setActionNotice({ tone: 'alert', message: formatOperatorError(error, 'Het initialisatieverzoek is niet bevestigd.') });
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-slate-700 bg-slate-900/50" aria-busy={refreshing || saving || initializing}>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-bold uppercase tracking-wider text-slate-300">
|
||||
<Database className="h-4 w-4 shrink-0 text-blue-400" /> Opslag voor nieuwe targets
|
||||
</CardTitle>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">
|
||||
Kies de vertrouwde opslagroot voor toekomstige registraties. Dit wijzigt of verplaatst geen bestaande targetregistraties.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="w-full shrink-0 border-slate-700 bg-slate-950 text-slate-200 sm:w-auto" onClick={() => void load()} disabled={!canManage || refreshing || saving || initializing}>
|
||||
{refreshing ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
{refreshing ? 'Status vernieuwen…' : 'Vernieuw status'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{!canManage && (
|
||||
<p className="text-sm text-slate-400">Alleen een beheerder met de instellingentoestemming kan deze opslagkeuze bekijken of wijzigen.</p>
|
||||
)}
|
||||
{canManage && refreshing && !snapshot && <p role="status" aria-live="polite" className="text-sm text-slate-400">Opslagstatus laden…</p>}
|
||||
{canManage && loadError && (
|
||||
<div role="alert" className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p>{snapshot ? `De laatste opslagstatus kon niet worden vernieuwd. De waarden hieronder zijn de laatst succesvol gelezen gegevens. ${loadError}` : loadError}</p>
|
||||
{!snapshot && <Button type="button" variant="outline" className="mt-3 border-amber-500/30" onClick={() => void load()}>Opnieuw proberen</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{snapshot && view && canManage && (
|
||||
<>
|
||||
<div className="grid min-w-0 gap-3 rounded-lg border border-slate-800 bg-slate-950/70 p-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Opgeslagen root</p>
|
||||
<p className="mt-1 break-all text-sm text-slate-100">{view.currentRoot || 'Niet bevestigd door de API'}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{view.currentRoot ? labelForRoot(view.currentRoot) : 'De huidige trustconfiguratie kan niet veilig worden gewijzigd.'}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Truststatus</p>
|
||||
<p className="mt-1 text-sm text-slate-100">
|
||||
{view.trustStatus === 'ready' ? 'Configuratie gelezen' : view.trustStatus === 'blocked' ? 'Trustconfiguratie heeft andere blockers' : view.trustStatus === 'needs_setup' ? 'Trustconfiguratie ontbreekt' : 'Status onbekend'}
|
||||
</p>
|
||||
{view.trustStatus === 'needs_setup' && <p className="mt-1 text-xs text-amber-200">Rond de trustconfiguratie eerst af via Setup.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.trustStatus === 'needs_setup' && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-sm text-amber-100">Deze beperkte kaart kan geen volledige trustconfiguratie maken.</div>
|
||||
)}
|
||||
|
||||
{snapshot.view.configured && (
|
||||
<fieldset disabled={saving || refreshing || initializing} className="min-w-0 space-y-3 disabled:opacity-80">
|
||||
<legend className="text-sm font-semibold text-slate-200">Vertrouwde opslagroot</legend>
|
||||
<RadioGroup
|
||||
aria-label="Vertrouwde opslagroot voor toekomstige targetregistraties"
|
||||
value={draft.root}
|
||||
onValueChange={(value) => {
|
||||
if (value !== LEGACY_APPDATA_ROOT && value !== MANAGED_APPDATA_ROOT) return;
|
||||
setSaveNotice('');
|
||||
setConflict('');
|
||||
updateDraft({ root: value, backupAcknowledged: false });
|
||||
}}
|
||||
className="grid min-w-0 gap-3 md:grid-cols-2"
|
||||
>
|
||||
<label htmlFor="appdata-root-legacy" className="flex min-w-0 cursor-pointer gap-3 rounded-lg border border-slate-700 bg-slate-950/70 p-3 has-[:disabled]:cursor-not-allowed">
|
||||
<RadioGroupItem id="appdata-root-legacy" value={LEGACY_APPDATA_ROOT} className="mt-1 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-slate-100">{labelForRoot(LEGACY_APPDATA_ROOT)}</span>
|
||||
<code className="mt-1 block break-all text-xs text-slate-400">{LEGACY_APPDATA_ROOT}</code>
|
||||
<span className="mt-2 block text-xs text-slate-300">{rootReadinessLabel(view.rootReadiness[LEGACY_APPDATA_ROOT])}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label htmlFor="appdata-root-managed" className="flex min-w-0 cursor-pointer gap-3 rounded-lg border border-slate-700 bg-slate-950/70 p-3 has-[:disabled]:cursor-not-allowed">
|
||||
<RadioGroupItem id="appdata-root-managed" value={MANAGED_APPDATA_ROOT} className="mt-1 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-slate-100">{labelForRoot(MANAGED_APPDATA_ROOT)}</span>
|
||||
<code className="mt-1 block break-all text-xs text-slate-400">{MANAGED_APPDATA_ROOT}</code>
|
||||
<span className="mt-2 block text-xs text-slate-300">{rootReadinessLabel(view.rootReadiness[MANAGED_APPDATA_ROOT])}</span>
|
||||
</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{draftIsManaged && snapshot.view.configured && (
|
||||
<section aria-labelledby="managed-root-backup-title" className="space-y-3 rounded-lg border border-amber-500/25 bg-amber-500/5 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<h3 id="managed-root-backup-title" className="text-sm font-semibold text-amber-100">Back-upverantwoordelijkheid voor DockerRootDir</h3>
|
||||
<p className="mt-1 break-words text-sm leading-6 text-amber-100/80">
|
||||
Deze vaste root ligt onder <code className="break-all">/var/lib/docker</code> en kan deel uitmaken van <code>docker.img</code>. Gebruikelijke back-ups van <code>/srv/example/appdata</code> nemen dit niet mee. Beheer en herstel van DockerRootDir vallen onder jouw back-upplan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-start gap-3 rounded-md border border-amber-500/20 bg-slate-950/60 p-3">
|
||||
<Checkbox
|
||||
id="managed-root-backup-ack"
|
||||
checked={draft.backupAcknowledged}
|
||||
onCheckedChange={(checked) => {
|
||||
setSaveNotice('');
|
||||
setConflict('');
|
||||
updateDraft({ ...draftRef.current, backupAcknowledged: checked === true });
|
||||
}}
|
||||
disabled={saving || refreshing || initializing}
|
||||
aria-describedby="managed-root-backup-copy"
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<Label htmlFor="managed-root-backup-ack" id="managed-root-backup-copy" className="cursor-pointer text-sm leading-6 text-slate-200">
|
||||
Ik bevestig dat reguliere appdata-back-ups DockerRootDir en <code>docker.img</code> niet dekken en dat ik daarvoor afzonderlijk back-up- en herstelbeheer regel.
|
||||
</Label>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-950/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controller-readiness van de beheerde volume</p>
|
||||
<p className="mt-1 text-sm text-slate-100">{managedStorageStatusLabel(view.managedStatus)}</p>
|
||||
{managedStorageBlockerLabel(view.managedBlocker) && <p className="mt-1 text-xs leading-5 text-amber-200">{managedStorageBlockerLabel(view.managedBlocker)}</p>}
|
||||
{!view.managedCapabilityValid && <p className="mt-1 text-xs text-amber-200">De controller leverde geen geldige readinessbevestiging; acties blijven geblokkeerd.</p>}
|
||||
<p className="mt-2 text-xs leading-5 text-slate-400">De rootkeuze geldt voor toekomstige registraties. Bestaande targets blijven aan hun eerder vastgelegde root gekoppeld en worden niet verplaatst.</p>
|
||||
</div>
|
||||
|
||||
{view.managedStatus === 'initialization_required' && (
|
||||
<div className="space-y-3 rounded-lg border border-slate-800 bg-slate-950/60 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-100">Eenmalige controlleractie</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-400">Alleen beschikbaar als de exacte handmatige high-risk policy-preview dit toestaat. Een aanvraag is geen bewijs dat de volume klaar is.</p>
|
||||
</div>
|
||||
{snapshot.previewError && <p role="alert" className="text-sm text-amber-200">Policy-preview niet beschikbaar: {snapshot.previewError}</p>}
|
||||
{snapshot.preview && (
|
||||
<div className="space-y-2 rounded-md border border-slate-800 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Policy: {snapshot.preview.policyDecision === 'allowed' ? 'Toegestaan' : snapshot.preview.policyDecision === 'approval_required' ? 'Approval vereist' : 'Geblokkeerd'}</p>
|
||||
<p className="break-words text-sm text-slate-200">{snapshot.preview.auditPreview}</p>
|
||||
{snapshot.preview.preChecks.length > 0 && (
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-400">
|
||||
{snapshot.preview.preChecks.map((check, index) => <li key={`${index}-${check}`} className="break-words">{check}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{(snapshot.preview.action !== MANAGED_STORAGE_INITIALIZE_ACTION || snapshot.preview.target !== MANAGED_STORAGE_TARGET) && (
|
||||
<p role="alert" className="text-xs text-rose-200">De preview bevestigt niet de vaste actie en controllerdoel; uitvoering blijft geblokkeerd.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{initializeAllowed && (
|
||||
<Button type="button" onClick={() => void initialize()} disabled={initializing || saving || refreshing} className="w-full sm:w-auto">
|
||||
{initializing ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <ShieldAlert className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
{initializing ? 'Actieverzoek indienen…' : 'Vraag volume-initialisatie aan'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionNotice && (
|
||||
<div role={actionNotice.tone === 'alert' ? 'alert' : 'status'} aria-live={actionNotice.tone === 'status' ? 'polite' : undefined} className={`rounded-lg border p-3 text-sm ${actionNotice.tone === 'alert' ? 'border-amber-500/30 bg-amber-500/5 text-amber-100' : 'border-blue-500/25 bg-blue-500/5 text-blue-100'}`}>
|
||||
{actionNotice.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && <div role="alert" className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-100">{conflict}</div>}
|
||||
{saveError && <div role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/5 p-3 text-sm text-rose-100">{saveError}</div>}
|
||||
{saveNotice && <div role="status" aria-live="polite" className="flex items-start gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-3 text-sm text-emerald-100"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />{saveNotice}</div>}
|
||||
|
||||
{view.configured && canManage && (
|
||||
<div className="flex flex-col gap-3 border-t border-slate-800 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-slate-400">Opslaan wijzigt alleen de trustinstelling voor volgende registraties.</p>
|
||||
<Button type="button" onClick={() => void save()} disabled={saveDisabled} className="w-full sm:w-auto">
|
||||
{saving ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
||||
{saving ? 'Opslaan…' : dirty ? 'Opslagkeuze opslaan' : 'Geen wijzigingen'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user