chore(frontend): delete never-mounted CutoverDrawer, MemberActionsCard, AccountSecurityNudge

This commit is contained in:
Juan Denis committed 2026-11-03 13:26:20 -05:00
1 parent 7a5991e079
commit e67562848a
8 files changed
+64 -1024

No files matched your search

-4
View File
@@ -22,10 +22,6 @@ const KEEP = {
'main.jsx': 'entry point',
'styles/main.scss': 'entry stylesheet',
'plugins/runtime/vendorManifest.js': 'read by the vendor-shim generator and extension bundle checks',
'components/domains/CutoverDrawer.jsx': 'built, never mounted: wire into Domains or delete (product call)',
'components/settings/MemberActionsCard.jsx': 'built, never mounted: wire in or delete (product call)',
'components/dashboard/AccountSecurityNudge.jsx': 'built, never mounted: wire in or delete (product call)',
'styles/pages/_cutover.scss': 'styles CutoverDrawer above; goes or gets imported with it',
'utils/snapshotBones.js': 'imported by scripts/capture-skeletons.mjs',
};
// Trees loaded by import.meta.glob, tests, and bundled extension copies.
@@ -1,75 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { Button } from '@/components/ui/button';
import { ShieldAlert, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Per-user "secure your account" nudge. Shows when the requesting
// user has no second factor, deep-linking to Settings → Security where the full
// guided enrolment lives (passkey-first, then TOTP with its QR). Dismissible;
// the dismissal is persisted to the account's `setup_snoozes` so it
// follows the user across browsers, with localStorage kept only as a fast cache
// so the nudge doesn't flash before the account fetch resolves.
const DISMISS_KEY = 'account_security_nudge_dismissed';
const ITEM_KEY = 'setup.account_security';
const SNOOZE_DAYS = 30;
const AccountSecurityNudge = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [item, setItem] = useState(null);
const [dismissed, setDismissed] = useState(() => {
try { return localStorage.getItem(DISMISS_KEY) === '1'; } catch { return false; }
});
useEffect(() => {
if (dismissed) return;
let cancelled = false;
api.getAccountSecurity()
.then((res) => {
if (cancelled) return;
const acct = (res.items || []).find((c) => c.key === ITEM_KEY);
// The account row is the source of truth: a server-side snooze
// (set from any browser) means "dismissed" here too.
if (acct && acct.snoozed) {
try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* ignore */ }
setDismissed(true);
return;
}
if (acct && acct.status !== 'ok') setItem(acct);
})
.catch(() => { /* stay quiet */ });
return () => { cancelled = true; };
}, [dismissed]);
if (dismissed || !item) return null;
const dismiss = () => {
// Cache locally for an instant hide, then persist to the account so the
// dismissal survives across browsers/devices. Best-effort: a failed
// persist still hides it for this session.
try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* ignore */ }
setDismissed(true);
api.snoozeSetupItem(ITEM_KEY, SNOOZE_DAYS).catch(() => { /* keep the local hide */ });
};
return (
<div className="account-security-nudge" role="status">
<ShieldAlert size={18} className="account-security-nudge__icon" />
<div className="account-security-nudge__body">
<span className="account-security-nudge__title">{t('app.accountSecurityNudge.secureYourAccount', 'Secure your account')}</span>
<span className="account-security-nudge__detail">{item.detail}</span>
</div>
<Button size="sm" onClick={() => navigate(item.fix?.to || '/settings/security')}>
{t('app.accountSecurityNudge.addAPasskeyOr2fa', 'Add a passkey or 2FA')}
</Button>
<Button variant="unstyled" type="button" className="account-security-nudge__close" onClick={dismiss} aria-label={t('common.actions.dismiss', 'Dismiss')}>
<X size={16} />
</Button>
</div>
);
};
export default AccountSecurityNudge;
@@ -1,484 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import {
ArrowRightLeft, ArrowLeft, ArrowRight, Camera, CheckCircle2, RotateCcw,
AlertTriangle, Globe, RadioTower,
} from 'lucide-react';
import { DataTable, Drawer, Pill } from '@/components/ds';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import api from '../../services/api';
import { useToast } from '../../contexts/useToast.js';
import Spinner from '../Spinner';
import { useTranslation } from 'react-i18next';
// Per-domain reversible DNS cutover. One shared slide-over,
// reached from the /domains detail drawer and from the Import wizard's final
// step, so the survey → migrate → cutover journey ends in the same place.
//
// Internal stages: target → review (snapshot + TTL guidance + dry-run diff) →
// verify (propagation check + one-click revert). Every write is snapshot-backed
// so it can always be rolled back.
//
// <CutoverDrawer open={open} onClose={close} domain="example.com"
// providerZoneId="abc123" provider="cloudflare"
// initialTarget="203.0.113.10" />
const HEAD_ICON_SIZE = 18;
const DRAWER_WIDTH = 720;
// Read-only step reports (the planned record changes, then per-resolver
// propagation): no sorting, no column menu.
const PLAN_COLUMNS = [
{
key: 'record_type',
headerKey: 'common.labels.type', header: 'Type',
cellClassName: 'sk-cell-dim',
render: (op) => op.record_type,
},
{
key: 'name',
headerKey: 'common.labels.name', header: 'Name',
cellClassName: 'sk-cell-mono',
render: (op) => op.name,
},
{
key: 'action',
headerKey: 'common.labels.action', header: 'Action',
render: (op) => <Pill kind={op.action === 'create' ? 'cyan' : 'amber'}>{op.action}</Pill>,
},
{
key: 'old_content',
headerKey: 'common.labels.current', header: 'Current',
cellClassName: 'sk-cell-mono',
render: (op) => op.old_content || '—',
},
{
key: 'new_content',
headerKey: 'app.cutoverDrawer.new', header: 'New',
cellClassName: 'sk-cell-mono',
render: (op) => op.new_content,
},
];
const RESOLVER_COLUMNS = [
{
key: 'nameserver',
headerKey: 'app.cutoverDrawer.resolver', header: 'Resolver',
cellClassName: 'sk-cell-mono',
render: (r) => r.nameserver,
},
{
key: 'result',
headerKey: 'app.cutoverDrawer.answer', header: 'Answer',
cellClassName: 'sk-cell-mono',
render: (r) => (r.result || []).join(', ') || '—',
},
{
key: 'status',
headerKey: 'common.labels.status', header: 'Status',
render: (r) => <Pill kind={r.propagated ? 'green' : 'gray'}>{r.propagated ? 'live' : 'stale'}</Pill>,
},
];
// Record types a cutover can repoint. A/AAAA/CNAME cover the "point at the new
// box" case; the operator keeps everything else untouched.
const RECORD_TYPE_CHOICES = ['A', 'AAAA', 'CNAME'];
const STAGES = [
{ id: 'target', labelKey: 'common.labels.target', label: 'Target' },
{ id: 'review', labelKey: 'app.cutoverDrawer.review', label: 'Review' },
{ id: 'verify', labelKey: 'app.cutoverDrawer.verify', label: 'Verify' },
];
// The provider guards (NO_PROVIDER / NO_ZONE) come back as 501/400 with a code;
// surface a plain-language line instead of a raw request-failed toast.
function friendlyError(err) {
const code = err?.data?.code;
if (code === 'NO_PROVIDER') {
return 'Connect a DNS provider first — the cutover reads and writes live records through it.';
}
if (code === 'NO_ZONE') {
return 'This domain has no connected provider zone, so its records can\'t be read or repointed.';
}
if (code === 'PROVIDER_READ_FAILED') {
return `Couldn't read the zone's live records: ${err.message}`;
}
return err?.message || 'Something went wrong.';
}
const CutoverDrawer = ({ open, onClose, domain, providerZoneId, provider, initialTarget }) => {
const { t } = useTranslation();
const toast = useToast();
const [stage, setStage] = useState('target');
const [target, setTarget] = useState('');
const [zoneId, setZoneId] = useState('');
const [recordTypes, setRecordTypes] = useState(['A']);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [snapshot, setSnapshot] = useState(null);
const [ttl, setTtl] = useState(null);
const [plan, setPlan] = useState(null);
const [cutoverResult, setCutoverResult] = useState(null);
const [verifyResult, setVerifyResult] = useState(null);
const [revertResult, setRevertResult] = useState(null);
const [confirmOpen, setConfirmOpen] = useState(false);
// Re-seed from props each time the drawer is opened, so a previous, possibly
// abandoned cutover never leaks into the next domain.
useEffect(() => {
if (!open) return;
setStage('target');
setTarget(initialTarget || '');
setZoneId(providerZoneId || '');
setRecordTypes(['A']);
setBusy(false);
setError('');
setSnapshot(null);
setTtl(null);
setPlan(null);
setCutoverResult(null);
setVerifyResult(null);
setRevertResult(null);
setConfirmOpen(false);
}, [open, initialTarget, providerZoneId]);
const toggleRecordType = (t) => {
setRecordTypes((prev) => (prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t]));
};
// target → review: snapshot the live records, derive TTL guidance from them,
// and compute the (provider-free) dry-run diff.
const captureAndReview = async () => {
setBusy(true);
setError('');
try {
const snap = await api.snapshotDnsForCutover(domain, zoneId.trim(), provider || null);
setSnapshot(snap);
const [guidance, dry] = await Promise.all([
api.getCutoverTtlGuidance(snap.records || []).catch(() => null),
api.performDnsCutover(snap.id, target.trim(), { recordTypes, dryRun: true }),
]);
setTtl(guidance);
setPlan(dry.plan || null);
setStage('review');
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
};
// review → verify: perform the real cutover through the guarded write path.
const applyCutover = async () => {
if (!snapshot) return;
setConfirmOpen(false);
setBusy(true);
setError('');
try {
const res = await api.performDnsCutover(snapshot.id, target.trim(), { recordTypes, dryRun: false });
setSnapshot(res.snapshot || snapshot);
setCutoverResult(res.cutover || null);
setStage('verify');
if (res.cutover?.success) {
toast.success(t('app.cutoverDrawer.cutoverAppliedNowPointsAt', 'Cutover applied. {{domain}} now points at {{value}}', { domain: domain, value: target.trim() }));
} else {
toast.error(t('app.cutoverDrawer.cutoverFinishedWithErrorsReviewThe', 'Cutover finished with errors. Review the results and consider reverting.'));
}
} catch (err) {
const message = friendlyError(err);
setError(message);
toast.error(message);
} finally {
setBusy(false);
}
};
const checkPropagation = useCallback(async () => {
setBusy(true);
setError('');
try {
const primaryType = recordTypes[0] || 'A';
const res = await api.verifyDnsCutover(domain, {
recordType: primaryType,
expected: target.trim(),
snapshotId: snapshot?.id || null,
});
setVerifyResult(res);
} catch (err) {
setError(friendlyError(err));
} finally {
setBusy(false);
}
}, [domain, recordTypes, target, snapshot]);
const revert = async () => {
if (!snapshot) return;
setBusy(true);
setError('');
try {
const res = await api.revertDnsCutover(snapshot.id);
setSnapshot(res.snapshot || snapshot);
setRevertResult(res.revert || null);
if (res.revert?.success) {
toast.success(t('app.cutoverDrawer.revertedRestoredSPreCutoverRecords', "Reverted: restored {{domain}}'s pre-cutover records", { domain: domain }));
} else {
toast.error(t('app.cutoverDrawer.revertFinishedWithErrorsCheckThe', 'Revert finished with errors. Check the results.'));
}
} catch (err) {
const message = friendlyError(err);
setError(message);
toast.error(message);
} finally {
setBusy(false);
}
};
const noZone = !zoneId.trim();
const canCapture = !!target.trim() && !noZone && recordTypes.length > 0;
const stageIndex = STAGES.findIndex((s) => s.id === stage);
return (
<Drawer
open={open}
onOpenChange={(v) => !v && onClose()}
title={t('app.cutoverDrawer.dnsCutover', 'DNS cutover')}
subtitle={<span className="mono">{domain}</span>}
icon={<ArrowRightLeft size={HEAD_ICON_SIZE} />}
width={DRAWER_WIDTH}
>
<div className="cutover">
<ol className="cutover__stepper">
{STAGES.map((s, i) => (
<li
key={s.id}
className={`cutover__step${i === stageIndex ? ' is-active' : ''}${i < stageIndex ? ' is-done' : ''}`}
>
<span className="cutover__step-dot">
{i < stageIndex ? <CheckCircle2 size={13} aria-hidden="true" /> : i + 1}
</span>
{s.label}
</li>
))}
</ol>
{error && (
<div className="cutover__callout cutover__callout--danger">
<AlertTriangle size={16} aria-hidden="true" />
<p>{error}</p>
</div>
)}
{/* Stage 1 — pick the target address + record types */}
{stage === 'target' && (
<div className="cutover__stage">
<p className="cutover__lead">
{t('app.cutoverDrawer.repoint', 'Repoint')} <strong>{domain}</strong> {t('app.cutoverDrawer.atTheBoxWhereTheImported', 'at the server where the imported site now lives. Its current records are snapshotted first, so the switch can always be reverted.')}
</p>
<div className="cutover__field">
<Label htmlFor="cutover-target">{t('app.cutoverDrawer.newAddressTarget', 'New address (target)')}</Label>
<Input
id="cutover-target"
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="203.0.113.10"
disabled={busy}
/>
<span className="cutover__hint">{t('app.cutoverDrawer.theIpOrHostnameForCname', 'The IP (or hostname for CNAME) the records should point to.')}</span>
</div>
<div className="cutover__field">
<Label htmlFor="cutover-zone">{t('app.cutoverDrawer.providerZoneId', 'Provider zone id')}</Label>
<Input
id="cutover-zone"
value={zoneId}
onChange={(e) => setZoneId(e.target.value)}
placeholder={t('app.cutoverDrawer.theDnsProviderSZoneIdentifier', 'the DNS provider\'s zone identifier')}
disabled={busy}
/>
{noZone && (
<span className="cutover__hint cutover__hint--warn">
{t('app.cutoverDrawer.thisDomainHasNoConnectedProvider', 'This domain has no connected provider zone. Cutover needs one to read and write records.')}
</span>
)}
</div>
<div className="cutover__field">
<Label>{t('app.cutoverDrawer.recordTypes', 'Record types')}</Label>
<div className="cutover__types">
{RECORD_TYPE_CHOICES.map((t) => (
<label key={t} className="cutover__type">
<input
type="checkbox"
checked={recordTypes.includes(t)}
onChange={() => toggleRecordType(t)}
disabled={busy}
/>
{t}
</label>
))}
</div>
</div>
<div className="cutover__actions">
<span />
<Button onClick={captureAndReview} disabled={busy || !canCapture}>
{busy ? <><Spinner size="sm" /> {t('app.cutoverDrawer.capturing', 'Capturing…')}</> : <><Camera size={14} /> {t('app.cutoverDrawer.snapshotPreview', 'Snapshot and preview')} <ArrowRight size={14} /></>}
</Button>
</div>
</div>
)}
{/* Stage 2 — snapshot summary, TTL guidance, dry-run diff */}
{stage === 'review' && (
<div className="cutover__stage">
<div className="cutover__summary">
<div className="cutover__stat">
<span className="cutover__stat-val">{snapshot?.record_count ?? 0}</span>
<span className="cutover__stat-label">{t('app.cutoverDrawer.recordsCaptured', 'records captured')}</span>
</div>
<div className="cutover__stat">
<span className="cutover__stat-val">{target.trim()}</span>
<span className="cutover__stat-label">{t('app.cutoverDrawer.newTarget', 'new target')}</span>
</div>
<div className="cutover__stat">
<span className="cutover__stat-val">{recordTypes.join(', ')}</span>
<span className="cutover__stat-label">{t('app.cutoverDrawer.recordTypes2', 'record types')}</span>
</div>
</div>
{ttl && (
<div className={`cutover__callout${ttl.needs_lowering ? ' cutover__callout--warning' : ' cutover__callout--info'}`}>
<RadioTower size={16} aria-hidden="true" />
<p>{ttl.message}</p>
</div>
)}
<section className="cutover__section">
<h3><Globe size={15} aria-hidden="true" /> {t('app.cutoverDrawer.plannedChanges', 'Planned changes')}</h3>
{plan?.ops?.length ? (
<DataTable
columns={PLAN_COLUMNS}
data={plan.ops.map((op, i) => ({ ...op, __idx: i }))}
keyField={(op) => `${op.record_type}-${op.name}-${op.__idx}`}
sortable={false}
columnMenu={false}
className="cutover__table-wrap"
/>
) : (
<p className="cutover__muted">{t('app.cutoverDrawer.noChangesToApplyForThe', 'No changes to apply for the selected record types.')}</p>
)}
</section>
<div className="cutover__actions">
<Button variant="outline" onClick={() => setStage('target')} disabled={busy}>
<ArrowLeft size={14} /> {t('common.actions.back', 'Back')}
</Button>
<Button onClick={() => setConfirmOpen(true)} disabled={busy || !plan?.ops?.length}>
{busy ? <><Spinner size="sm" /> {t('app.cutoverDrawer.applying', 'Applying…')}</> : <><ArrowRightLeft size={14} /> {t('app.cutoverDrawer.applyCutover', 'Apply cutover')}</>}
</Button>
</div>
</div>
)}
{/* Stage 3 — cutover result, verify, revert */}
{stage === 'verify' && (
<div className="cutover__stage">
{cutoverResult && (
<div className={`cutover__callout${cutoverResult.success ? ' cutover__callout--success' : ' cutover__callout--danger'}`}>
{cutoverResult.success
? <CheckCircle2 size={16} aria-hidden="true" />
: <AlertTriangle size={16} aria-hidden="true" />}
<p>
{cutoverResult.success
? <>{t('app.cutoverDrawer.cutoverApplied', 'Cutover applied:')} <strong>{domain}</strong> {t('app.cutoverDrawer.nowPointsAt', 'now points at')} <code>{target.trim()}</code>.</>
: <>{t('app.cutoverDrawer.cutoverFinishedWithErrorsReviewBelow', 'Cutover finished with errors. Review below and revert if needed.')}</>}
</p>
</div>
)}
<section className="cutover__section">
<h3><RadioTower size={15} aria-hidden="true" /> {t('app.cutoverDrawer.propagation', 'Propagation')}</h3>
<p className="cutover__muted">
{t('app.cutoverDrawer.publicResolversCacheTheOldAddress', 'Public resolvers cache the old address until their TTL expires. Check where the new address has taken effect.')}
</p>
{verifyResult && (
<div className="cutover__verify-head">
{verifyResult.matches_expected != null && (
<Pill kind={verifyResult.matches_expected ? 'green' : 'amber'}>
{verifyResult.matches_expected ? t('app.cutoverDrawer.matchesNewTarget', 'Matches new target') : t('app.cutoverDrawer.notFullyPropagated', 'Not fully propagated')}
</Pill>
)}
<Pill kind={verifyResult.propagated ? 'green' : 'gray'}>
{verifyResult.propagated ? t('app.cutoverDrawer.propagated', 'Propagated') : t('app.cutoverDrawer.propagating', 'Propagating')}
</Pill>
</div>
)}
{verifyResult?.resolvers?.length > 0 && (
<DataTable
columns={RESOLVER_COLUMNS}
data={verifyResult.resolvers.map((r, i) => ({ ...r, __idx: i }))}
keyField={(r) => `${r.nameserver}-${r.__idx}`}
sortable={false}
columnMenu={false}
className="cutover__table-wrap"
/>
)}
<Button variant="outline" size="sm" onClick={checkPropagation} disabled={busy}>
{busy ? <><Spinner size="sm" /> {t('common.checking', 'Checking…')}</> : <><RadioTower size={14} /> {t('app.cutoverDrawer.checkPropagation', 'Check propagation')}</>}
</Button>
</section>
<section className="cutover__section cutover__section--revert">
<h3><RotateCcw size={15} aria-hidden="true" /> {t('app.cutoverDrawer.rollBack', 'Roll back')}</h3>
{revertResult ? (
<div className={`cutover__callout${revertResult.success ? ' cutover__callout--success' : ' cutover__callout--danger'}`}>
{revertResult.success
? <CheckCircle2 size={16} aria-hidden="true" />
: <AlertTriangle size={16} aria-hidden="true" />}
<p>
{t('app.cutoverDrawer.restoredRecords', { count: revertResult.results?.length ?? 0, defaultValue_one: 'Restored 1 record.', defaultValue_other: 'Restored {{count}} records.' })}
{revertResult.deleted_count
? ` ${t('app.cutoverDrawer.deletedCreatedRecords', { count: revertResult.deleted_count, defaultValue_one: 'Deleted 1 record the cutover created.', defaultValue_other: 'Deleted {{count}} records the cutover created.' })}`
: ''}
</p>
</div>
) : (
<p className="cutover__muted">
{t('app.cutoverDrawer.oneClickRestoresTheSnapshotCaptured', 'One click restores the snapshot captured before this cutover, including deleting any records the cutover created.')}
</p>
)}
<Button variant="outline" size="sm" onClick={revert} disabled={busy || !!revertResult}>
{busy ? <><Spinner size="sm" /> {t('app.cutoverDrawer.reverting', 'Reverting…')}</> : <><RotateCcw size={14} /> {t('app.cutoverDrawer.revertCutover', 'Revert cutover')}</>}
</Button>
</section>
<div className="cutover__actions">
<span />
<Button variant="outline" onClick={onClose} disabled={busy}>{t('common.actions.done', 'Done')}</Button>
</div>
</div>
)}
<ConfirmDialog
isOpen={confirmOpen}
title={t('app.cutoverDrawer.applyDnsCutover', 'Apply DNS cutover?')}
message={t('app.cutoverDrawer.repointsRecords', { count: recordTypes.length, types: recordTypes.join(', '), domain: domain, target: target.trim(), defaultValue_one: 'This repoints the {{types}} record for {{domain}} at {{target}}. The snapshot lets you revert.', defaultValue_other: 'This repoints the {{types}} records for {{domain}} at {{target}}. The snapshot lets you revert.' })}
confirmText={t('app.cutoverDrawer.applyCutover', 'Apply cutover')}
variant="danger"
onConfirm={applyCutover}
onCancel={() => setConfirmOpen(false)}
/>
</div>
</Drawer>
);
};
export default CutoverDrawer;
@@ -0,0 +1,55 @@
import { ShieldAlert, ShieldCheck } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
// Release-signature verdict shared by the registry detail modal and the manual
// install consent card. `signature.status`: 'verified' (ed25519 signature
// checked against a publisher key this panel pins), 'untrusted_key',
// 'invalid', or anything else = unsigned.
export default function SignatureBadge({ signature, className }) {
const { t } = useTranslation();
const status = signature?.status || 'unsigned';
const signer = signature?.publisher || signature?.key_id;
if (status === 'verified') {
return (
<Badge
variant="success"
className={className}
title={t('app.signatureBadge.verifiedHint', 'Release zip is signed; the panel verifies the ed25519 signature at install')}
>
<ShieldCheck aria-hidden="true" />
{t('app.signatureBadge.signed', 'Signed')}{signer ? ` · ${signer}` : ''}
</Badge>
);
}
if (status === 'untrusted_key') {
return (
<Badge
variant="warning"
className={className}
title={signer ? t('app.signatureBadge.unknownSignerHint', "Signed with publisher key '{{key}}', which this panel does not pin", { key: signer }) : undefined}
>
<ShieldAlert aria-hidden="true" />
{t('app.signatureBadge.unknownSigner', 'Unknown signer')}
</Badge>
);
}
if (status === 'invalid') {
return (
<Badge variant="destructive" className={className}>
<ShieldAlert aria-hidden="true" />
{t('app.signatureBadge.invalid', 'Signature invalid')}
</Badge>
);
}
return (
<Badge
variant="warning"
className={className}
title={t('app.signatureBadge.unsignedHint', 'This release carries no publisher signature')}
>
<ShieldAlert aria-hidden="true" />
{t('app.signatureBadge.unsigned', 'Unsigned')}
</Badge>
);
}
@@ -1,79 +0,0 @@
import { useEffect, useState } from 'react';
import api from '../../services/api';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
// Admin control for the member-action escape hatch.
// Curated, parameterized actions a workspace member may run for an app they can
// reach (no free text ever reaches a shell; every run is audit-logged). On by
// default — this surface just makes the flag visible so an operator can turn the
// whole thing off. The templates are deliberately conservative, so the default
// stays ON.
const MemberActionsCard = () => {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(true);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState(null);
useEffect(() => {
api.getSystemSettings()
.then((data) => {
if (data.security_member_actions_enabled != null) {
setEnabled(Boolean(data.security_member_actions_enabled));
}
})
.catch(() => { /* leave default */ })
.finally(() => setLoading(false));
}, []);
async function handleSave() {
setSaving(true);
setMessage(null);
try {
await api.updateSystemSettings({ security_member_actions_enabled: enabled });
setMessage({ type: 'success', text: 'Member actions setting saved.' });
} catch (err) {
setMessage({ type: 'error', text: err.message || 'Failed to save setting' });
} finally {
setSaving(false);
setTimeout(() => setMessage(null), 6000);
}
}
if (loading) return null;
return (
<div className="settings-card">
<h3>{t('app.memberActionsCard.memberActions', 'Member actions')}</h3>
<p className="form-help form-help--flush">
{t('app.memberActionsCard.letWorkspaceMembersRunASmall', "Let workspace members run a small set of curated, parameterized actions (like adjusting a service's backup frequency) for services they can reach. Every action is validated against a fixed schema and audit-logged, so no free text ever reaches the server shell. Turn this off to disable the whole member-action surface.")}
</p>
<div className="form-group">
<label className="member-check-label">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<span>{t('app.memberActionsCard.allowMembersToRunCuratedActions', 'Allow members to run curated actions')}</span>
</label>
</div>
<div className="form-actions">
<Button variant="default" onClick={handleSave} disabled={saving}>
{saving ? t('common.saving', 'Saving…') : t('common.actions.save', 'Save')}
</Button>
{message && (
<span className={`timezone-message timezone-message--inline ${message.type}`}>
{message.text}
</span>
)}
</div>
</div>
);
};
export default MemberActionsCard;
+9 -69
View File
@@ -228,10 +228,6 @@
"viewRelease": "View release",
"youReUpToDate": "You're up to date"
},
"accountSecurityNudge": {
"addAPasskeyOr2fa": "Add a passkey or 2FA",
"secureYourAccount": "Secure your account"
},
"activityTab": {
"actionType": "Action type",
"actionsThisWeek": "actions this week",
@@ -1654,56 +1650,6 @@
"scheduleIsRequired": "Schedule is required",
"serverOffline": "Server offline"
},
"cutoverDrawer": {
"answer": "Answer",
"applyCutover": "Apply cutover",
"applyDnsCutover": "Apply DNS cutover?",
"applying": "Applying…",
"atTheBoxWhereTheImported": "at the server where the imported site now lives. Its current records are snapshotted first, so the switch can always be reverted.",
"capturing": "Capturing…",
"checkPropagation": "Check propagation",
"cutoverApplied": "Cutover applied:",
"cutoverAppliedNowPointsAt": "Cutover applied. {{domain}} now points at {{value}}",
"cutoverFinishedWithErrorsReviewBelow": "Cutover finished with errors. Review below and revert if needed.",
"cutoverFinishedWithErrorsReviewThe": "Cutover finished with errors. Review the results and consider reverting.",
"deletedCreatedRecords_one": "Deleted 1 record the cutover created.",
"deletedCreatedRecords_other": "Deleted {{count}} records the cutover created.",
"dnsCutover": "DNS cutover",
"matchesNewTarget": "Matches new target",
"new": "New",
"newAddressTarget": "New address (target)",
"newTarget": "new target",
"noChangesToApplyForThe": "No changes to apply for the selected record types.",
"notFullyPropagated": "Not fully propagated",
"nowPointsAt": "now points at",
"oneClickRestoresTheSnapshotCaptured": "One click restores the snapshot captured before this cutover, including deleting any records the cutover created.",
"plannedChanges": "Planned changes",
"propagated": "Propagated",
"propagating": "Propagating",
"propagation": "Propagation",
"providerZoneId": "Provider zone id",
"publicResolversCacheTheOldAddress": "Public resolvers cache the old address until their TTL expires. Check where the new address has taken effect.",
"recordTypes": "Record types",
"recordTypes2": "record types",
"recordsCaptured": "records captured",
"repoint": "Repoint",
"repointsRecords_one": "This repoints the {{types}} record for {{domain}} at {{target}}. The snapshot lets you revert.",
"repointsRecords_other": "This repoints the {{types}} records for {{domain}} at {{target}}. The snapshot lets you revert.",
"resolver": "Resolver",
"restoredRecords_one": "Restored 1 record.",
"restoredRecords_other": "Restored {{count}} records.",
"revertCutover": "Revert cutover",
"revertFinishedWithErrorsCheckThe": "Revert finished with errors. Check the results.",
"revertedRestoredSPreCutoverRecords": "Reverted: restored {{domain}}'s pre-cutover records",
"reverting": "Reverting…",
"review": "Review",
"rollBack": "Roll back",
"snapshotPreview": "Snapshot and preview",
"theDnsProviderSZoneIdentifier": "the DNS provider's zone identifier",
"theIpOrHostnameForCname": "The IP (or hostname for CNAME) the records should point to.",
"thisDomainHasNoConnectedProvider": "This domain has no connected provider zone. Cutover needs one to read and write records.",
"verify": "Verify"
},
"dashGrid": {
"noWidgetsOnThisDashboardYet": "No widgets on this dashboard yet."
},
@@ -3617,15 +3563,11 @@
"noHostPermissionsDeclared": "No host permissions declared.",
"pasteAGithubRepoOwnerRepo": "Paste a GitHub repo (owner/repo or owner/repo@tag), a release URL, or a direct zip link. You'll preview what installs before it lands.",
"preview": "Preview",
"signatureInvalid": "Signature invalid",
"signatureMismatch": "The signature does not match this archive.",
"signed": "Signed ·",
"signedUnknownPublisher": "Signed · unknown publisher",
"theDownloadMayHaveBeenTampered": "The download may have been tampered with. Do not install it.",
"thisExtensionRequests": "This extension requests:",
"thisReleaseCarriesNoPublisherSignature": "This release carries no publisher signature. It will run with full panel privileges, so install it only if you trust the source.",
"thisReleaseIsSignedButThe": "This release is signed, but the publisher key is not pinned by this panel, so the signature cannot be verified. Treat it as unsigned.",
"unsigned": "Unsigned",
"uploadAnExtensionZipWith": "Upload an extension zip with",
"useAnAbsolutePathThatExists": "Use an absolute path that exists on the backend host or inside the backend container.",
"zip": "Zip"
@@ -3694,34 +3636,23 @@
"purgeData": "Delete data",
"registry": "Registry",
"registryInstallFailed": "Couldn't install from the registry.",
"releaseZipIsSignedThePanel": "Release zip is signed; the panel verifies the ed25519 signature at install",
"removingThisExtensionStopsItsRoutes": "Uninstalling this extension stops its routes and UI contributions.",
"resetFilters": "Reset filters",
"reviewed": "Reviewed",
"screenshots": "Screenshots",
"searchExtensions": "Search extensions…",
"signed": "Signed",
"signedWithPublisherKeyWhichThis": "Signed with publisher key '{{publisherKeyId}}', which this panel does not pin",
"sourceRepo": "Source repo",
"thisExtensionDeclaresNoConfigurationFields": "This extension has no settings.",
"thisExtensionRequests": "This extension requests:",
"thisReleaseCarriesNoPublisherSignature": "This release carries no publisher signature",
"uninstall": "Uninstall {{displayname}}?",
"uninstalling": "Uninstalling…",
"unknownSigner": "Unknown signer",
"unreviewed": "Unreviewed",
"unsigned": "Unsigned",
"update": "Update",
"updateAnyway": "Update anyway",
"updateAvailableV": "Update available → v",
"updating": "Updating…",
"versionByAuthor": "v{{version}} · by {{author}}"
},
"memberActionsCard": {
"allowMembersToRunCuratedActions": "Allow members to run curated actions",
"letWorkspaceMembersRunASmall": "Let workspace members run a small set of curated, parameterized actions (like adjusting a service's backup frequency) for services they can reach. Every action is validated against a fixed schema and audit-logged, so no free text ever reaches the server shell. Turn this off to disable the whole member-action surface.",
"memberActions": "Member actions"
},
"message": {
"somethingWentWrong": "Something went wrong."
},
@@ -6414,6 +6345,15 @@
"viewProfiles": "View profiles",
"visibleCount": "{{visible}} of {{total}} visible"
},
"signatureBadge": {
"invalid": "Signature invalid",
"signed": "Signed",
"unknownSigner": "Unknown signer",
"unknownSignerHint": "Signed with publisher key '{{key}}', which this panel does not pin",
"unsigned": "Unsigned",
"unsignedHint": "This release carries no publisher signature",
"verifiedHint": "Release zip is signed; the panel verifies the ed25519 signature at install"
},
"siteSettingsTab": {
"addBaseDomain": "Add base domain",
"adding": "Adding…",
@@ -107,50 +107,3 @@
&__summary { display: none; }
}
}
// Per-user "secure your account" nudge banner (dashboard top).
.account-security-nudge {
display: flex;
align-items: center;
gap: $space-3;
padding: $space-3 $space-4;
margin-bottom: $space-4;
border: 1px solid var(--warning-border, rgba(245, 179, 66, 0.35));
border-radius: var(--radius, #{$radius-card});
background: var(--warning-bg, rgba(245, 179, 66, 0.08));
&__icon {
color: $warning;
flex-shrink: 0;
}
&__body {
display: flex;
flex-direction: column;
gap: $space-hair;
flex: 1;
min-width: 0;
}
&__title {
font-size: $text-sm;
font-weight: $font-weight-semibold;
color: $text-primary;
}
&__detail {
font-size: $text-xs;
color: $text-secondary;
}
&__close {
border: 0;
background: transparent;
color: $text-tertiary;
cursor: pointer;
padding: $space-1;
display: inline-flex;
&:hover { color: $text-primary; }
}
}
-266
View File
@@ -1,266 +0,0 @@
// ============================================
// DNS CUTOVER (reversible per-domain repoint)
// Slide-over launched from /domains and the Import wizard. Mirrors the
// import-wizard idiom (stepper + callouts + compact tables).
// ============================================
.cutover {
display: flex;
flex-direction: column;
gap: $space-5;
h3 {
display: flex;
align-items: center;
gap: $space-2;
margin: 0 0 $space-3;
font-size: $text-base;
font-weight: $font-weight-semibold;
color: $text-primary;
}
// --------------------------------------------
// Stepper
// --------------------------------------------
&__stepper {
display: flex;
align-items: center;
gap: $space-2;
margin: 0;
padding: 0;
list-style: none;
}
&__step {
display: flex;
align-items: center;
gap: $space-2;
padding: $space-2 $space-3;
border-radius: $radius-full;
font-size: $text-xs;
color: $text-tertiary;
white-space: nowrap;
&.is-active {
@include status-variant($text-primary, $accent-bg);
.cutover__step-dot {
background: $accent-primary;
border-color: $accent-primary;
color: #fff;
}
}
&.is-done {
color: $text-secondary;
.cutover__step-dot {
color: $success;
border-color: $success-border;
}
}
& + &::before {
content: '';
width: 16px;
height: 1px;
margin-right: $space-2;
background: $border-default;
}
}
&__step-dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px; // geometry: stepper dot
border: 1px solid $border-default;
border-radius: $radius-full;
font-size: $text-xs;
font-weight: $font-weight-semibold;
flex-shrink: 0;
}
// --------------------------------------------
// Stage body
// --------------------------------------------
&__stage {
display: flex;
flex-direction: column;
gap: $space-4;
}
&__lead {
margin: 0;
font-size: $text-xs;
line-height: 1.6;
color: $text-secondary;
code {
font-family: $font-mono;
font-size: $text-xs;
padding: 1px $space-tight;
background: $bg-secondary;
border-radius: $radius-badge;
}
}
&__muted {
margin: 0;
color: $text-tertiary;
font-size: $text-xs;
}
&__field {
display: flex;
flex-direction: column;
gap: $space-2;
}
&__hint {
font-size: $text-xs;
color: $text-tertiary;
&--warn {
color: $warning;
}
}
&__types {
display: flex;
gap: $space-3;
flex-wrap: wrap;
}
&__type {
display: inline-flex;
align-items: center;
gap: $space-2;
padding: $space-2 $space-3;
background: $bg-elevated;
border: 1px solid $border-subtle;
border-radius: $radius-control;
font-size: $text-xs;
color: $text-primary;
cursor: pointer;
}
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: $space-3;
margin-top: $space-2;
}
// --------------------------------------------
// Review summary
// --------------------------------------------
&__summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: $space-3;
}
&__stat {
display: flex;
flex-direction: column;
gap: $space-hair;
padding: $space-3 $space-4;
background: $bg-elevated;
border: 1px solid $border-subtle;
border-radius: $radius-control;
}
&__stat-val {
font-size: $text-base;
font-weight: $font-weight-semibold;
color: $text-primary;
word-break: break-word;
}
&__stat-label {
font-size: $text-xs;
color: $text-tertiary;
}
// --------------------------------------------
// Tables (ops diff + resolver rows)
// --------------------------------------------
&__table-wrap {
margin-bottom: $space-3;
}
&__verify-head {
display: flex;
gap: $space-2;
margin-bottom: $space-3;
flex-wrap: wrap;
}
&__section--revert {
padding-top: $space-4;
border-top: 1px solid $border-subtle;
}
// --------------------------------------------
// Callouts
// --------------------------------------------
&__callout {
display: flex;
gap: $space-3;
padding: $space-3 $space-4;
border-radius: $radius-control;
font-size: $text-xs;
svg {
flex-shrink: 0;
margin-top: $space-hair;
}
p {
margin: 0;
code {
font-family: $font-mono;
font-size: $text-xs;
padding: 1px $space-tight;
background: $bg-secondary;
border-radius: $radius-badge;
}
}
&--info {
background: $accent-bg;
border: 1px solid $accent-primary;
color: $text-secondary;
svg { color: $accent-primary; }
}
&--warning {
background: $warning-bg;
border: 1px solid $warning-border;
color: $warning;
p { color: $text-secondary; }
}
&--danger {
background: $danger-bg;
border: 1px solid $danger-border;
color: $danger;
p { color: $text-secondary; }
}
&--success {
background: $success-bg;
border: 1px solid $success-border;
color: $success;
p { color: $text-secondary; }
}
}
}