mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-10 05:53:05 +09:00
chore(frontend): delete never-mounted CutoverDrawer, MemberActionsCard, AccountSecurityNudge
This commit is contained in:
1 parent
7a5991e079
commit
e67562848a
8 files changed
+64
-1024
No files matched your search
@@ -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;
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user