diff --git a/frontend/scripts/check-orphan-files.mjs b/frontend/scripts/check-orphan-files.mjs index b0d8435c..7340c394 100644 --- a/frontend/scripts/check-orphan-files.mjs +++ b/frontend/scripts/check-orphan-files.mjs @@ -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. diff --git a/frontend/src/components/dashboard/AccountSecurityNudge.jsx b/frontend/src/components/dashboard/AccountSecurityNudge.jsx deleted file mode 100644 index 0f5fd9cb..00000000 --- a/frontend/src/components/dashboard/AccountSecurityNudge.jsx +++ /dev/null @@ -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 ( -
- -
- {t('app.accountSecurityNudge.secureYourAccount', 'Secure your account')} - {item.detail} -
- - -
- ); -}; - -export default AccountSecurityNudge; diff --git a/frontend/src/components/domains/CutoverDrawer.jsx b/frontend/src/components/domains/CutoverDrawer.jsx deleted file mode 100644 index b408accc..00000000 --- a/frontend/src/components/domains/CutoverDrawer.jsx +++ /dev/null @@ -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. -// -// - -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) => {op.action}, - }, - { - 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) => {r.propagated ? 'live' : 'stale'}, - }, -]; - -// 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 ( - !v && onClose()} - title={t('app.cutoverDrawer.dnsCutover', 'DNS cutover')} - subtitle={{domain}} - icon={} - width={DRAWER_WIDTH} - > -
-
    - {STAGES.map((s, i) => ( -
  1. - - {i < stageIndex ? - {s.label} -
  2. - ))} -
- - {error && ( -
-
- )} - - {/* Stage 1 — pick the target address + record types */} - {stage === 'target' && ( -
-

- {t('app.cutoverDrawer.repoint', 'Repoint')} {domain} {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.')} -

- -
- - setTarget(e.target.value)} - placeholder="203.0.113.10" - disabled={busy} - /> - {t('app.cutoverDrawer.theIpOrHostnameForCname', 'The IP (or hostname for CNAME) the records should point to.')} -
- -
- - setZoneId(e.target.value)} - placeholder={t('app.cutoverDrawer.theDnsProviderSZoneIdentifier', 'the DNS provider\'s zone identifier')} - disabled={busy} - /> - {noZone && ( - - {t('app.cutoverDrawer.thisDomainHasNoConnectedProvider', 'This domain has no connected provider zone. Cutover needs one to read and write records.')} - - )} -
- -
- -
- {RECORD_TYPE_CHOICES.map((t) => ( - - ))} -
-
- -
- - -
-
- )} - - {/* Stage 2 — snapshot summary, TTL guidance, dry-run diff */} - {stage === 'review' && ( -
-
-
- {snapshot?.record_count ?? 0} - {t('app.cutoverDrawer.recordsCaptured', 'records captured')} -
-
- {target.trim()} - {t('app.cutoverDrawer.newTarget', 'new target')} -
-
- {recordTypes.join(', ')} - {t('app.cutoverDrawer.recordTypes2', 'record types')} -
-
- - {ttl && ( -
-
- )} - -
-

- {plan?.ops?.length ? ( - ({ ...op, __idx: i }))} - keyField={(op) => `${op.record_type}-${op.name}-${op.__idx}`} - sortable={false} - columnMenu={false} - className="cutover__table-wrap" - /> - ) : ( -

{t('app.cutoverDrawer.noChangesToApplyForThe', 'No changes to apply for the selected record types.')}

- )} -
- -
- - -
-
- )} - - {/* Stage 3 — cutover result, verify, revert */} - {stage === 'verify' && ( -
- {cutoverResult && ( -
- {cutoverResult.success - ?
- )} - -
-

-

- {t('app.cutoverDrawer.publicResolversCacheTheOldAddress', 'Public resolvers cache the old address until their TTL expires. Check where the new address has taken effect.')} -

- - {verifyResult && ( -
- {verifyResult.matches_expected != null && ( - - {verifyResult.matches_expected ? t('app.cutoverDrawer.matchesNewTarget', 'Matches new target') : t('app.cutoverDrawer.notFullyPropagated', 'Not fully propagated')} - - )} - - {verifyResult.propagated ? t('app.cutoverDrawer.propagated', 'Propagated') : t('app.cutoverDrawer.propagating', 'Propagating')} - -
- )} - - {verifyResult?.resolvers?.length > 0 && ( - ({ ...r, __idx: i }))} - keyField={(r) => `${r.nameserver}-${r.__idx}`} - sortable={false} - columnMenu={false} - className="cutover__table-wrap" - /> - )} - - -
- -
-

- {revertResult ? ( -
- {revertResult.success - ?
- ) : ( -

- {t('app.cutoverDrawer.oneClickRestoresTheSnapshotCaptured', 'One click restores the snapshot captured before this cutover, including deleting any records the cutover created.')} -

- )} - -
- -
- - -
-
- )} - - setConfirmOpen(false)} - /> -
-
- ); -}; - -export default CutoverDrawer; diff --git a/frontend/src/components/marketplace/SignatureBadge.jsx b/frontend/src/components/marketplace/SignatureBadge.jsx new file mode 100644 index 00000000..3db05977 --- /dev/null +++ b/frontend/src/components/marketplace/SignatureBadge.jsx @@ -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 ( + + + ); + } + if (status === 'untrusted_key') { + return ( + + + ); + } + if (status === 'invalid') { + return ( + + + ); + } + return ( + + + ); +} diff --git a/frontend/src/components/settings/MemberActionsCard.jsx b/frontend/src/components/settings/MemberActionsCard.jsx deleted file mode 100644 index 11fd2f6e..00000000 --- a/frontend/src/components/settings/MemberActionsCard.jsx +++ /dev/null @@ -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 ( -
-

{t('app.memberActionsCard.memberActions', 'Member actions')}

-

- {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.")} -

- -
- -
- -
- - {message && ( - - {message.text} - - )} -
-
- ); -}; - -export default MemberActionsCard; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 642f0d12..b546f613 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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…", diff --git a/frontend/src/styles/components/_setup-health.scss b/frontend/src/styles/components/_setup-health.scss index 20d5585e..11c6eb5d 100644 --- a/frontend/src/styles/components/_setup-health.scss +++ b/frontend/src/styles/components/_setup-health.scss @@ -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; } - } -} diff --git a/frontend/src/styles/pages/_cutover.scss b/frontend/src/styles/pages/_cutover.scss deleted file mode 100644 index 961f3f92..00000000 --- a/frontend/src/styles/pages/_cutover.scss +++ /dev/null @@ -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; } - } - } -}