portal-mojo 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { detectTemporal } from '../../ui/date/fns';
|
|
2
|
+
import type { AddressPrecheck, Attempt, Bootstrap, Certificate, Collector, DeploymentStatus, OnboardingOperation, OnboardingOptions, PlatformAttempt, PlatformSnapshot, Proof, Release, WebApp, WebAppList, WebAppObject, WebAppSummary } from './types';
|
|
3
|
+
export const record = (v: unknown): Record<string, unknown> => v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
|
4
|
+
export const text = (v: unknown, max = 256): string => typeof v === 'string' ? v.slice(0, max).replace(/[\u0000-\u0008\u000b-\u001f]/g, '') : typeof v === 'number' && Number.isFinite(v) ? String(v) : '';
|
|
5
|
+
export function dateText(v: unknown): string { const temporal = detectTemporal(v); if (!temporal)
|
|
6
|
+
return text(v); const d = new Date(temporal.ms); return Number.isNaN(d.getTime()) ? text(v) : d.toISOString(); }
|
|
7
|
+
export const positiveId = (v: unknown): number => typeof v === 'number' && Number.isSafeInteger(v) && v > 0 ? v : 0;
|
|
8
|
+
export const count = (v: unknown): number => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : 0;
|
|
9
|
+
export const list = (v: unknown, max = 128): unknown[] => Array.isArray(v) ? v.slice(0, max) : [];
|
|
10
|
+
export function uuid(v: unknown): string { const s = text(v, 36); return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(s) ? s : ''; }
|
|
11
|
+
function certificate(v: unknown): Certificate | null { if (!v)
|
|
12
|
+
return null; const r = record(v); return { status: text(r.status) || 'unknown', not_after: text(r.not_after) }; }
|
|
13
|
+
export function projectRelease(v: unknown): Release { const r = record(v); return {
|
|
14
|
+
id: positiveId(r.id), version: text(r.version), status: text(r.status) || 'unknown', source: text(r.source) || 'unknown', created: text(r.created)
|
|
15
|
+
}; }
|
|
16
|
+
export function projectWebApp(v: unknown): WebApp { const r = record(v); return {
|
|
17
|
+
id: positiveId(r.id), slug: text(r.slug, 64), display_name: text(r.display_name, 120), environment: text(r.environment), deployment_ref: text(r.deployment_ref), repository: text(r.repository ?? r.github_repository), build_output: text(r.build_output)
|
|
18
|
+
}; }
|
|
19
|
+
export function projectWebAppObject(v: unknown): WebAppObject { const r = record(v), g = record(r.group); return { ...projectWebApp(r), group: { id: positiveId(g.id), name: text(g.name) } }; }
|
|
20
|
+
export function projectAttempt(v: unknown): Attempt { const r = record(v); return {
|
|
21
|
+
id: positiveId(r.id), status: text(r.status) || 'unknown', created: dateText(r.created), started: dateText(r.started), finished: dateText(r.finished), detail: text(r.detail, 1500), release: r.release ? projectRelease(r.release) : null
|
|
22
|
+
}; }
|
|
23
|
+
export function projectSummary(v: unknown): WebAppSummary { const r = record(v), a = record(r.address), o = record(r.onboarding), k = record(r.deployment_key); return {
|
|
24
|
+
webapp: projectWebApp(r.webapp), address: r.address ? {
|
|
25
|
+
hostname: text(a.hostname), certificate: certificate(a.certificate), aliases: list(a.aliases, 32).map(x => { const v = record(x); return { hostname: text(v.hostname), certificate: certificate(v.certificate) }; })
|
|
26
|
+
} : null, current_release: r.current_release ? projectRelease(r.current_release) : null, latest_deployment: r.latest_deployment ? projectAttempt(r.latest_deployment) : null, onboarding: { status: text(o.status) || 'unknown', cursor: text(o.cursor) }, deployment_key: { linked: k.linked === true, active: k.active === true }
|
|
27
|
+
}; }
|
|
28
|
+
export function projectSummaries(v: unknown): WebAppList { const r = record(v); if (r.schema_version !== 1)
|
|
29
|
+
throw new Error('Unsupported WebApp summary schema'); return {
|
|
30
|
+
items: list(r.items, 50).map(projectSummary).filter(x => x.webapp.id > 0), count: count(r.count), limit: count(r.limit), truncated: r.truncated === true
|
|
31
|
+
}; }
|
|
32
|
+
function proof(v: unknown): Proof { const r = record(v), p = record(r.proof), d = record(r.detail); return {
|
|
33
|
+
runner: text(r.runner ?? r.runner_id), status: text(r.status ?? r.state ?? r.kind) || 'unknown', detail: text(typeof r.detail === 'string' ? r.detail : d.message ?? d.reason ?? d.phase ?? r.reason ?? r.message, 1500), sha: text(p.platform_sha), deployment: uuid(p.platform_deployment), stderr_tail: text(d.stderr_tail ?? r.stderr_tail, 2000)
|
|
34
|
+
}; }
|
|
35
|
+
export function projectPlatformAttempt(v: unknown): PlatformAttempt { const r = record(v), n = record(r.node_summary), d = record(r.detail); return {
|
|
36
|
+
id: uuid(r.id), sha: text(r.sha, 40), framework_version: text(r.framework_version), status: text(r.status) || 'unknown', source: text(r.source), actor: text(r.actor), retry_of: uuid(r.retry_of), created: dateText(r.created), started: dateText(r.started), finished: dateText(r.finished), duration_seconds: typeof r.duration_seconds === 'number' ? r.duration_seconds : null, desired_commit: text(r.desired_commit, 40), current_commits: list(r.current_commits).map(x => text(x, 40)), frozen_roster: list(r.frozen_roster).map(x => text(x)), transitions: list(r.transitions).map(proof), node_evidence: list(r.node_evidence).map(proof), diagnosis: list(r.diagnosis).map(proof), detail: text(typeof r.detail === 'string' ? r.detail : d.message ?? d.reason, 1500), node_summary: {
|
|
37
|
+
expected: count(n.expected), proven: count(n.proven), reported: count(n.reported), failed: count(n.failed), dispatched: count(n.dispatched), other: count(n.other)
|
|
38
|
+
}
|
|
39
|
+
}; }
|
|
40
|
+
function collector<T>(v: unknown, project: (v: unknown) => T): Collector<T> { const r = record(v); return {
|
|
41
|
+
status: text(r.status) || 'unknown', observed_at: text(r.observed_at ?? r.checked_at), stale_after: dateText(r.stale_after), reason: text(r.reason_detail ?? r.reason, 500), data: r.data && ['healthy', 'degraded', 'unhealthy', 'unknown', 'ok', 'unconfigured', 'stale'].includes(String(r.status)) ? project(r.data) : null
|
|
42
|
+
}; }
|
|
43
|
+
export function projectPlatform(v: unknown): PlatformSnapshot { const r = record(v), s = record(r.sections); if (r.schema_version !== 1)
|
|
44
|
+
throw new Error('Unsupported platform schema'); return { deployments: collector(s.deployments, v => { const d = record(v), c = record(d.currently_serving), o = record(d.coordination), f = record(d.framework_pin); return {
|
|
45
|
+
items: list(d.items, 50).map(projectPlatformAttempt).filter(x => x.id), limit: count(d.limit), desired_commit: text(d.desired_commit), desired_deployment: uuid(d.desired_deployment), currently_serving: d.currently_serving ? {
|
|
46
|
+
deployment: uuid(c.deployment), sha: text(c.sha, 40), framework_version: text(c.framework_version), converged_at: dateText(c.converged_at)
|
|
47
|
+
} : null, coordination: {
|
|
48
|
+
state: text(o.state), deployment: uuid(o.deployment), sha: text(o.sha, 40), at: text(o.at)
|
|
49
|
+
}, framework_pin: { mode: text(f.mode), resolved: text(f.resolved) }
|
|
50
|
+
}; }), api: collector(s.api, v => { const a = record(v); return { version: text(a.version), commit: text(a.commit) }; }) }; }
|
|
51
|
+
export function projectBootstrap(v: unknown): Bootstrap { const r = record(v); return {
|
|
52
|
+
groups: list(r.webapp_groups, 128).map(v => { const g = record(v); return {
|
|
53
|
+
id: positiveId(g.id), name: text(g.name), can_manage_dns: g.can_manage_dns === true
|
|
54
|
+
}; }).filter(x => x.id > 0), can_create_webapp_group: r.can_create_webapp_group === true, setup: record(r.capabilities).setup === true
|
|
55
|
+
}; }
|
|
56
|
+
export function projectDeployment(v: unknown): DeploymentStatus { const r = record(v), status = text(r.status) || 'unknown'; return {
|
|
57
|
+
deployment: positiveId(r.deployment), webapp: positiveId(r.webapp), release: positiveId(r.release), version: text(r.version), status, terminal: ['live', 'failed', 'rolled_back', 'superseded'].includes(status) && r.terminal === true, success: status === 'live' && r.success === true, detail: text(r.detail, 1500), created: dateText(r.created), started: dateText(r.started), finished: dateText(r.finished), targets: list(r.targets).map(proof), rollback_targets: list(r.rollback_targets).map(proof)
|
|
58
|
+
}; }
|
|
59
|
+
export function projectOperation(v: unknown): OnboardingOperation { const r = record(v), g = record(r.group), p = record(r.profile), e = record(r.evidence), a = record(e.app), d = record(e.address), h = record(e.github), f = record(e.verify), resources = record(r.resources); if (r.schema_version !== 1 || !uuid(r.operation_id))
|
|
60
|
+
throw new Error('Invalid onboarding operation'); return {
|
|
61
|
+
operation_id: uuid(r.operation_id), group: { id: positiveId(g.id), name: text(g.name) }, status: text(r.status) || 'unknown', cursor: text(r.cursor) || 'unknown', revision: count(r.revision), profile: { slug: text(p.slug, 64), display_name: text(p.display_name, 120) }, evidence: {
|
|
62
|
+
app: { status: text(a.status) }, address: { status: text(d.status), hostname: text(d.hostname) }, github: { status: text(h.status), reason: text(h.reason) }, verify: { status: text(f.status), detail: text(f.detail) }
|
|
63
|
+
}, activity: list(r.activity, 64).map(v => { const x = record(v); return {
|
|
64
|
+
message: text(x.message, 500), kind: text(x.kind), at: dateText(x.at)
|
|
65
|
+
}; }), resources: { webapp: positiveId(resources.webapp) || null }, next_attempt_at: dateText(r.next_attempt_at), last_error: text(r.last_error, 1500), modified: dateText(r.modified)
|
|
66
|
+
}; }
|
|
67
|
+
export function projectOptions(v: unknown): OnboardingOptions { const r = record(v), d = record(r.apps_domain); if (r.schema_version !== 1)
|
|
68
|
+
throw new Error('Unsupported onboarding options'); return {
|
|
69
|
+
buckets: list(r.buckets, 64).map(x => text(x)).filter(Boolean), environments: list(r.environments, 8).map(x => text(x)), destination: !!r.destination, destination_error: text(r.destination_error, 500), apps_domain: r.apps_domain ? { id: positiveId(d.id), name: text(d.name) } : null, apps_domain_error: text(r.apps_domain_error, 500), github_connected: r.github_connected === true
|
|
70
|
+
}; }
|
|
71
|
+
export function projectPrecheck(v: unknown): AddressPrecheck { const r = record(v), d = record(r.domain); return {
|
|
72
|
+
verdict: text(r.verdict) || 'unknown', hostname: text(r.hostname ?? record(r.normalized).hostname), reason: text(r.reason, 500), domain: r.domain ? { id: positiveId(d.id), name: text(d.name) } : null, label: text(r.label ?? record(r.normalized).label)
|
|
73
|
+
}; }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
2
|
+
import { deploymentTone } from './deployment-state';
|
|
3
|
+
import type { DeploymentOwner } from './permissions';
|
|
4
|
+
import { assertOwner } from './permissions';
|
|
5
|
+
export { FleetHelp as DeploymentHelp } from '../fleet/FleetHelp';
|
|
6
|
+
export function Status({ value }: {
|
|
7
|
+
value: string;
|
|
8
|
+
}) { return <span className={`deployment-status text-${deploymentTone(value)}`}>{value || 'unknown'}</span>; }
|
|
9
|
+
export function Facts({ rows }: {
|
|
10
|
+
rows: [
|
|
11
|
+
string,
|
|
12
|
+
ReactNode
|
|
13
|
+
][];
|
|
14
|
+
}) { return <dl className="deployment-facts">{rows.map(([label, value]) => <div key={label}><dt>{label}</dt><dd>{value || 'Unknown'}</dd></div>)}</dl>; }
|
|
15
|
+
export function ErrorNotice({ children }: {
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}) { return <p role="alert" className="text-bad">{children}</p>; }
|
|
18
|
+
/** A ref locks the initiating event before React's next render. */
|
|
19
|
+
export function useWrite(owner: DeploymentOwner, signal: AbortSignal, onBusy?: (busy: boolean) => void) { const lock = useRef(false), alive = useRef(true); const [busy, setBusy] = useState(false), [error, setError] = useState(''); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); return {
|
|
20
|
+
busy, error, run: async (fn: () => Promise<void>) => { if (lock.current)
|
|
21
|
+
return; lock.current = true; setBusy(true); setError(''); onBusy?.(true); try {
|
|
22
|
+
assertOwner(owner, signal);
|
|
23
|
+
await fn();
|
|
24
|
+
assertOwner(owner, signal);
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
if (alive.current && !signal.aborted)
|
|
28
|
+
setError(e instanceof Error ? e.message : 'Operation outcome unknown. Refresh to reconcile.');
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
lock.current = false;
|
|
32
|
+
if (alive.current) {
|
|
33
|
+
setBusy(false);
|
|
34
|
+
onBusy?.(false);
|
|
35
|
+
}
|
|
36
|
+
} }
|
|
37
|
+
}; }
|
|
38
|
+
export function useCloseOnOwnerChange(key: string, onClose: () => void) { const previous = useRef(key); useEffect(() => { if (previous.current !== key)
|
|
39
|
+
onClose(); previous.current = key; }, [key, onClose]); }
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/** Safe display DTOs. Raw server objects and upload/key material never reach Query. */
|
|
2
|
+
export interface Release {
|
|
3
|
+
id: number;
|
|
4
|
+
version: string;
|
|
5
|
+
status: string;
|
|
6
|
+
source: string;
|
|
7
|
+
created: string;
|
|
8
|
+
}
|
|
9
|
+
export interface WebApp {
|
|
10
|
+
id: number;
|
|
11
|
+
slug: string;
|
|
12
|
+
display_name: string;
|
|
13
|
+
environment: string;
|
|
14
|
+
deployment_ref: string;
|
|
15
|
+
repository: string;
|
|
16
|
+
build_output: string;
|
|
17
|
+
}
|
|
18
|
+
export interface WebAppObject extends WebApp {
|
|
19
|
+
group: {
|
|
20
|
+
id: number;
|
|
21
|
+
name: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface Certificate {
|
|
25
|
+
status: string;
|
|
26
|
+
not_after: string;
|
|
27
|
+
}
|
|
28
|
+
export interface Attempt {
|
|
29
|
+
id: number;
|
|
30
|
+
started?: string;
|
|
31
|
+
detail?: string;
|
|
32
|
+
status: string;
|
|
33
|
+
created: string;
|
|
34
|
+
finished: string;
|
|
35
|
+
release: Release | null;
|
|
36
|
+
}
|
|
37
|
+
export interface WebAppSummary {
|
|
38
|
+
webapp: WebApp;
|
|
39
|
+
address: {
|
|
40
|
+
hostname: string;
|
|
41
|
+
certificate: Certificate | null;
|
|
42
|
+
aliases: {
|
|
43
|
+
hostname: string;
|
|
44
|
+
certificate: Certificate | null;
|
|
45
|
+
}[];
|
|
46
|
+
} | null;
|
|
47
|
+
current_release: Release | null;
|
|
48
|
+
latest_deployment: Attempt | null;
|
|
49
|
+
onboarding: {
|
|
50
|
+
status: string;
|
|
51
|
+
cursor: string;
|
|
52
|
+
};
|
|
53
|
+
deployment_key: {
|
|
54
|
+
linked: boolean;
|
|
55
|
+
active: boolean;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export interface WebAppList {
|
|
59
|
+
items: WebAppSummary[];
|
|
60
|
+
count: number;
|
|
61
|
+
limit: number;
|
|
62
|
+
truncated: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface Proof {
|
|
65
|
+
runner: string;
|
|
66
|
+
status: string;
|
|
67
|
+
detail: string;
|
|
68
|
+
sha: string;
|
|
69
|
+
deployment: string;
|
|
70
|
+
stderr_tail: string;
|
|
71
|
+
}
|
|
72
|
+
export interface PlatformAttempt {
|
|
73
|
+
id: string;
|
|
74
|
+
sha: string;
|
|
75
|
+
framework_version: string;
|
|
76
|
+
status: string;
|
|
77
|
+
source: string;
|
|
78
|
+
actor: string;
|
|
79
|
+
retry_of: string;
|
|
80
|
+
created: string;
|
|
81
|
+
started: string;
|
|
82
|
+
finished: string;
|
|
83
|
+
duration_seconds: number | null;
|
|
84
|
+
desired_commit: string;
|
|
85
|
+
current_commits: string[];
|
|
86
|
+
frozen_roster: string[];
|
|
87
|
+
transitions: Proof[];
|
|
88
|
+
node_evidence: Proof[];
|
|
89
|
+
diagnosis: Proof[];
|
|
90
|
+
detail: string;
|
|
91
|
+
node_summary: Record<'expected' | 'proven' | 'reported' | 'failed' | 'dispatched' | 'other', number>;
|
|
92
|
+
}
|
|
93
|
+
export interface Collector<T> {
|
|
94
|
+
status: string;
|
|
95
|
+
observed_at: string;
|
|
96
|
+
stale_after: string;
|
|
97
|
+
reason: string;
|
|
98
|
+
data: T | null;
|
|
99
|
+
}
|
|
100
|
+
export interface PlatformSnapshot {
|
|
101
|
+
deployments: Collector<{
|
|
102
|
+
items: PlatformAttempt[];
|
|
103
|
+
limit: number;
|
|
104
|
+
desired_commit: string;
|
|
105
|
+
desired_deployment: string;
|
|
106
|
+
currently_serving: {
|
|
107
|
+
deployment: string;
|
|
108
|
+
sha: string;
|
|
109
|
+
framework_version: string;
|
|
110
|
+
converged_at: string;
|
|
111
|
+
} | null;
|
|
112
|
+
coordination: {
|
|
113
|
+
state: string;
|
|
114
|
+
deployment: string;
|
|
115
|
+
sha: string;
|
|
116
|
+
at: string;
|
|
117
|
+
};
|
|
118
|
+
framework_pin: {
|
|
119
|
+
mode: string;
|
|
120
|
+
resolved: string;
|
|
121
|
+
};
|
|
122
|
+
}>;
|
|
123
|
+
api: Collector<{
|
|
124
|
+
version: string;
|
|
125
|
+
commit: string;
|
|
126
|
+
}>;
|
|
127
|
+
}
|
|
128
|
+
export interface Bootstrap {
|
|
129
|
+
groups: {
|
|
130
|
+
id: number;
|
|
131
|
+
name: string;
|
|
132
|
+
can_manage_dns: boolean;
|
|
133
|
+
}[];
|
|
134
|
+
can_create_webapp_group: boolean;
|
|
135
|
+
setup: boolean;
|
|
136
|
+
}
|
|
137
|
+
export interface DeploymentStatus {
|
|
138
|
+
deployment: number;
|
|
139
|
+
webapp: number;
|
|
140
|
+
release: number;
|
|
141
|
+
version: string;
|
|
142
|
+
status: string;
|
|
143
|
+
terminal: boolean;
|
|
144
|
+
success: boolean;
|
|
145
|
+
detail: string;
|
|
146
|
+
created: string;
|
|
147
|
+
started: string;
|
|
148
|
+
finished: string;
|
|
149
|
+
targets: Proof[];
|
|
150
|
+
rollback_targets: Proof[];
|
|
151
|
+
}
|
|
152
|
+
export interface CreateProfile {
|
|
153
|
+
operation_id: string;
|
|
154
|
+
group?: number;
|
|
155
|
+
group_intent?: 'new';
|
|
156
|
+
slug: string;
|
|
157
|
+
display_name: string;
|
|
158
|
+
environment: string;
|
|
159
|
+
bucket: string;
|
|
160
|
+
github_repository: string;
|
|
161
|
+
deployment_ref: string;
|
|
162
|
+
build_output: string;
|
|
163
|
+
}
|
|
164
|
+
export interface OnboardingOperation {
|
|
165
|
+
operation_id: string;
|
|
166
|
+
group: {
|
|
167
|
+
id: number;
|
|
168
|
+
name: string;
|
|
169
|
+
};
|
|
170
|
+
status: string;
|
|
171
|
+
cursor: string;
|
|
172
|
+
revision: number;
|
|
173
|
+
profile: {
|
|
174
|
+
slug: string;
|
|
175
|
+
display_name: string;
|
|
176
|
+
};
|
|
177
|
+
evidence: {
|
|
178
|
+
app: {
|
|
179
|
+
status: string;
|
|
180
|
+
};
|
|
181
|
+
address: {
|
|
182
|
+
status: string;
|
|
183
|
+
hostname: string;
|
|
184
|
+
};
|
|
185
|
+
github: {
|
|
186
|
+
status: string;
|
|
187
|
+
reason: string;
|
|
188
|
+
};
|
|
189
|
+
verify: {
|
|
190
|
+
status: string;
|
|
191
|
+
detail: string;
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
activity: {
|
|
195
|
+
message: string;
|
|
196
|
+
kind: string;
|
|
197
|
+
at: string;
|
|
198
|
+
}[];
|
|
199
|
+
resources: {
|
|
200
|
+
webapp: number | null;
|
|
201
|
+
};
|
|
202
|
+
next_attempt_at: string;
|
|
203
|
+
last_error: string;
|
|
204
|
+
modified: string;
|
|
205
|
+
}
|
|
206
|
+
export interface OnboardingOptions {
|
|
207
|
+
buckets: string[];
|
|
208
|
+
environments: string[];
|
|
209
|
+
destination: boolean;
|
|
210
|
+
destination_error: string;
|
|
211
|
+
apps_domain: {
|
|
212
|
+
id: number;
|
|
213
|
+
name: string;
|
|
214
|
+
} | null;
|
|
215
|
+
apps_domain_error: string;
|
|
216
|
+
github_connected: boolean;
|
|
217
|
+
}
|
|
218
|
+
export interface AddressPrecheck {
|
|
219
|
+
verdict: string;
|
|
220
|
+
hostname: string;
|
|
221
|
+
reason: string;
|
|
222
|
+
domain: {
|
|
223
|
+
id: number;
|
|
224
|
+
name: string;
|
|
225
|
+
} | null;
|
|
226
|
+
label: string;
|
|
227
|
+
}
|
|
228
|
+
export interface ManifestEntry {
|
|
229
|
+
path: string;
|
|
230
|
+
sha256: string;
|
|
231
|
+
size: number;
|
|
232
|
+
}
|
|
233
|
+
export type ApiAction = 'retry' | 'verify' | 'converge';
|
|
234
|
+
/** Registered router paths supplied by the host, already checked against its section roster. */
|
|
235
|
+
export interface DeploymentsDestinations {
|
|
236
|
+
domains?: string;
|
|
237
|
+
serving?: string;
|
|
238
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ManifestEntry } from './types';
|
|
2
|
+
export function normalizeUploadPath(path: string): string { const p = path.normalize('NFC'); if (!p || p.length > 1024 || p.startsWith('/') || p.includes('\\') || /[\u0000-\u001f]/.test(p) || p.split('/').some(x => !x || x === '.' || x === '..') || /^[a-z]:/i.test(p))
|
|
3
|
+
throw new Error('Choose normalized relative file paths without traversal or empty segments'); return p; }
|
|
4
|
+
export async function buildManifest(files: File[], signal?: AbortSignal, limits = { files: 5000, bytes: 1024 ** 3 }): Promise<ManifestEntry[]> { if (!files.length || files.length > limits.files || files.reduce((n, f) => n + f.size, 0) > limits.bytes)
|
|
5
|
+
throw new Error(`Select 1–${limits.files} files, at most ${limits.bytes} bytes. This installation may impose lower limits.`); const seen = new Set<string>(), manifest: ManifestEntry[] = []; for (const file of files) {
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
const path = normalizeUploadPath(file.webkitRelativePath || file.name);
|
|
8
|
+
if (seen.has(path))
|
|
9
|
+
throw new Error(`Duplicate normalized file path: ${path}`);
|
|
10
|
+
seen.add(path);
|
|
11
|
+
const hash = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
manifest.push({
|
|
14
|
+
path, sha256: [...new Uint8Array(hash)].map(x => x.toString(16).padStart(2, '0')).join(''), size: file.size
|
|
15
|
+
});
|
|
16
|
+
} return manifest.sort((a, b) => a.path.localeCompare(b.path)); }
|
|
17
|
+
export function manifestEqual(a: ManifestEntry[], b: ManifestEntry[]): boolean { return JSON.stringify(a) === JSON.stringify(b); }
|
|
18
|
+
export async function putReleaseFiles(files: File[], manifest: ManifestEntry[], uploads: {
|
|
19
|
+
path: string;
|
|
20
|
+
url: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
}[], signal: AbortSignal, onProgress: (n: number) => void, put: typeof fetch = fetch): Promise<void> { if (uploads.length === 0)
|
|
23
|
+
return; if (uploads.length !== manifest.length)
|
|
24
|
+
throw new Error('Incomplete signed upload manifest'); const source = new Map(files.map(f => [normalizeUploadPath(f.webkitRelativePath || f.name), f])), seen = new Set<string>(); let completed = 0; for (const upload of uploads) {
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
const entry = manifest.find(x => x.path === upload.path), file = source.get(upload.path);
|
|
27
|
+
if (!entry || !file || seen.has(upload.path))
|
|
28
|
+
throw new Error('Upload path does not match the selected manifest');
|
|
29
|
+
seen.add(upload.path);
|
|
30
|
+
let url: URL;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(upload.url);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error('Invalid storage upload destination');
|
|
36
|
+
}
|
|
37
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
38
|
+
throw new Error('Storage requires an HTTPS signed destination');
|
|
39
|
+
const expected = btoa(String.fromCharCode(...Uint8Array.from(entry.sha256.match(/../g) ?? [], x => parseInt(x, 16))));
|
|
40
|
+
if (Object.keys(upload.headers).length !== 1 || upload.headers['x-amz-checksum-sha256'] !== expected)
|
|
41
|
+
throw new Error('Storage checksum headers do not match this file');
|
|
42
|
+
try {
|
|
43
|
+
const response = await put(url.href, {
|
|
44
|
+
method: 'PUT', headers: upload.headers, body: file, credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', signal
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new Error('upload refused');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error('Storage upload failed. Check the bucket CORS policy permits anonymous PUT and x-amz-checksum-sha256 from this Admin origin. Completion was not sent.');
|
|
51
|
+
}
|
|
52
|
+
onProgress(++completed);
|
|
53
|
+
} }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useLayoutEffect, useState } from 'react';
|
|
2
|
+
import { getAuthSessionRevision, useAuthSnapshot, useMe } from '../../client/runtime';
|
|
3
|
+
import { backendGlobalCan } from '../fleet/access';
|
|
4
|
+
import { assertOwner, deploymentOwner, ownerKey, type DeploymentOwner } from './permissions';
|
|
5
|
+
/** One identity/scope lifetime, including React's development-only effect replay. */
|
|
6
|
+
export function useDeploymentOwner() {
|
|
7
|
+
const auth = useAuthSnapshot(), me = useMe(), revision = getAuthSessionRevision();
|
|
8
|
+
const base = useMemo(() => deploymentOwner(), [auth.uid, auth.authenticated, revision]);
|
|
9
|
+
const authority = [me.data?.is_active !== false, ...[
|
|
10
|
+
'view_platform', 'manage_platform', 'admin', 'view_dns', 'manage_dns',
|
|
11
|
+
'security', 'manage_webapp', 'view_admin', 'manage_users', 'manage_settings',
|
|
12
|
+
].map(permission => backendGlobalCan(me.data, permission))].join(':');
|
|
13
|
+
const key = `${ownerKey(base)}:${authority}`;
|
|
14
|
+
const lifetime = useMemo(() => ({ controller: new AbortController() }), [key]);
|
|
15
|
+
const [, renderLiveSignal] = useState(0);
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
// StrictMode replays setup after cleanup without replacing query keys.
|
|
18
|
+
// Renew this same scope before its read effects resume, and publish the
|
|
19
|
+
// new signal to consumers that capture a concrete signal during render.
|
|
20
|
+
if (lifetime.controller.signal.aborted) {
|
|
21
|
+
lifetime.controller = new AbortController();
|
|
22
|
+
renderLiveSignal(generation => generation + 1);
|
|
23
|
+
}
|
|
24
|
+
const mountedController = lifetime.controller;
|
|
25
|
+
return () => mountedController.abort();
|
|
26
|
+
}, [lifetime]);
|
|
27
|
+
// Query callbacks from the first render must see the controller belonging
|
|
28
|
+
// to the current setup, rather than a permanently aborted render snapshot.
|
|
29
|
+
// A genuine unmount has no following setup; its lifetime remains aborted.
|
|
30
|
+
// A different actor/session/authority receives a separate lifetime object.
|
|
31
|
+
const owner = useMemo<DeploymentOwner>(() => ({
|
|
32
|
+
...base,
|
|
33
|
+
get signal() { return lifetime.controller.signal; },
|
|
34
|
+
}), [base, lifetime]);
|
|
35
|
+
return { owner, signal: lifetime.controller.signal, key };
|
|
36
|
+
}
|
|
37
|
+
export interface Observation<T> {
|
|
38
|
+
value: T | null;
|
|
39
|
+
state: 'idle' | 'observing' | 'stopped' | 'terminal' | 'error';
|
|
40
|
+
observedAt: number | null;
|
|
41
|
+
refresh: () => void;
|
|
42
|
+
}
|
|
43
|
+
/** Known identity only; bounded, owner-fenced, hidden/blur aware; refresh only reads. */
|
|
44
|
+
export function useDeploymentObservation<T>(owner: DeploymentOwner, identity: string | null, read: (signal: AbortSignal) => Promise<T>, terminal: (value: T) => boolean, pollable: (value: T) => boolean = () => true, budgetMs = 60000, intervalMs = 2000): Observation<T> {
|
|
45
|
+
const [generation, setGeneration] = useState(0), [value, setValue] = useState<T | null>(null), [state, setState] = useState<Observation<T>['state']>('idle'), [observedAt, setObservedAt] = useState<number | null>(null);
|
|
46
|
+
const current = useRef({
|
|
47
|
+
read, terminal, pollable
|
|
48
|
+
});
|
|
49
|
+
current.current = {
|
|
50
|
+
read, terminal, pollable
|
|
51
|
+
};
|
|
52
|
+
const key = ownerKey(owner), slot = `${key}:${identity}:${generation}`;
|
|
53
|
+
const seen = useRef('');
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
seen.current = slot;
|
|
56
|
+
if (!identity) {
|
|
57
|
+
setValue(null);
|
|
58
|
+
setState('idle');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
let cancelled = false;
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
setValue(null);
|
|
66
|
+
setObservedAt(null);
|
|
67
|
+
setState('observing');
|
|
68
|
+
const tick = async () => { if (cancelled)
|
|
69
|
+
return; if (Date.now() - started >= budgetMs) {
|
|
70
|
+
setState('stopped');
|
|
71
|
+
return;
|
|
72
|
+
} if (typeof document !== 'undefined' && (document.visibilityState === 'hidden' || !document.hasFocus())) {
|
|
73
|
+
timer = setTimeout(tick, intervalMs);
|
|
74
|
+
return;
|
|
75
|
+
} try {
|
|
76
|
+
assertOwner(owner, controller.signal);
|
|
77
|
+
const next = await current.current.read(controller.signal);
|
|
78
|
+
assertOwner(owner, controller.signal);
|
|
79
|
+
if (cancelled)
|
|
80
|
+
return;
|
|
81
|
+
setValue(next);
|
|
82
|
+
setObservedAt(Date.now());
|
|
83
|
+
if (current.current.terminal(next)) {
|
|
84
|
+
setState('terminal');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!current.current.pollable(next)) {
|
|
88
|
+
setState('stopped');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
timer = setTimeout(tick, intervalMs);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
if (!cancelled)
|
|
95
|
+
setState('error');
|
|
96
|
+
} };
|
|
97
|
+
void tick();
|
|
98
|
+
return () => { cancelled = true; controller.abort(); clearTimeout(timer); };
|
|
99
|
+
}, [key, owner.signal, identity, generation, budgetMs, intervalMs]);
|
|
100
|
+
const matching = seen.current === slot;
|
|
101
|
+
return {
|
|
102
|
+
value: matching ? value : null, state: matching ? state : identity ? 'observing' : 'idle', observedAt: matching ? observedAt : null, refresh: useCallback(() => setGeneration(n => n + 1), [])
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -6,6 +6,9 @@ import { revokeCertificate, useAcmeDelegations, useDnsCapabilities } from './api
|
|
|
6
6
|
import {
|
|
7
7
|
canInspectHouseCertificate, certificateStatusTone, deriveCertificateRenewalHealth,
|
|
8
8
|
} from './certificate-data';
|
|
9
|
+
import { SharedValidationPanel } from './SharedValidationPanel';
|
|
10
|
+
import { openSharedValidation } from './SharedValidationDialog';
|
|
11
|
+
import { useValidationPlan } from './shared-validation-api';
|
|
9
12
|
import { CertificateModel, DNS_MANAGE_PERMISSIONS, type CertificateRow, type DomainRow } from './models';
|
|
10
13
|
|
|
11
14
|
function healthLabel(row: CertificateRow): string {
|
|
@@ -23,6 +26,7 @@ function AllowedCertificateDetail({ id, domain, onClose }: { id: number; domain:
|
|
|
23
26
|
const groupId = typeof domain.group === 'number' ? domain.group : domain.group?.id ?? null;
|
|
24
27
|
const capabilities = useDnsCapabilities(groupId);
|
|
25
28
|
const delegations = useAcmeDelegations(domain.id);
|
|
29
|
+
const shared = useValidationPlan({ domain: domain.id });
|
|
26
30
|
const [revoking, setRevoking] = useState(false);
|
|
27
31
|
const certificate = query.data;
|
|
28
32
|
if (query.isPending) return <div className="modal-pad dim">Loading certificate…</div>;
|
|
@@ -89,17 +93,18 @@ function AllowedCertificateDetail({ id, domain, onClose }: { id: number; domain:
|
|
|
89
93
|
</div> },
|
|
90
94
|
{ key: 'delegation', label: 'Delegation', icon: 'bi-diagram-2', render: () => <div className="dns-cert-detail-section">
|
|
91
95
|
<Eyebrow>DNS-01 routing</Eyebrow>
|
|
92
|
-
{delegations.isPending ? <p className="dim">Loading delegation status…</p> : delegation ? <>
|
|
96
|
+
{delegations.isPending ? <p className="dim">Loading delegation status…</p> : delegations.error ? <p className="dns-errors">Delegation status could not be loaded. DNS routing is unknown.</p> : delegation ? <>
|
|
93
97
|
<FlatRow label="State"><Badge tone={delegation.state === 'verified' ? 'success' : delegation.state === 'broken' ? 'danger' : 'warning'}>{delegation.state}</Badge></FlatRow>
|
|
94
98
|
<FlatRow label="Source"><code>{delegation.source ?? '—'}</code></FlatRow>
|
|
95
99
|
<FlatRow label="Target"><code>{delegation.target ?? '—'}</code></FlatRow>
|
|
96
100
|
{delegation.last_error_code && <FlatRow label="Error code"><code>{delegation.last_error_code}</code></FlatRow>}
|
|
97
101
|
{delegation.verified_at && delegation.state === 'broken' && <div className="dns-errors">This delegation is sticky after verification. Repair it; issuance will not fall back to direct provider DNS.</div>}
|
|
98
|
-
</> : <p className="dim">
|
|
102
|
+
</> : <p className="dim">{shared.data?.connection?.mode === 'shared' ? 'An explicit shared connection controls validation; direct DNS fallback is disabled.' : shared.error || shared.loading ? 'Shared routing status is unavailable; no direct-routing claim can be made.' : 'No legacy delegated ACME route is attached.'}</p>}
|
|
103
|
+
<SharedValidationPanel domain={domain} />
|
|
99
104
|
</div> },
|
|
100
105
|
]}
|
|
101
106
|
initialSection="overview"
|
|
102
|
-
contextMenu={canManage && certificate.status === 'active' ? [{ label: revoking ? 'Revoking…' : 'Revoke certificate', icon: 'bi-x-octagon', danger: true, onSelect: () => void revoke() }] : []}
|
|
107
|
+
contextMenu={[...(canManage && shared.data?.capabilities.group_choices ? [{ label: 'Set up shared validation', icon: 'bi-diagram-2', onSelect: () => void openSharedValidation(domain) }] : []), ...(canManage && certificate.status === 'active' ? [{ label: revoking ? 'Revoking…' : 'Revoke certificate', icon: 'bi-x-octagon', danger: true, onSelect: () => void revoke() }] : [])]}
|
|
103
108
|
menuContext={certificate}
|
|
104
109
|
onClose={onClose}
|
|
105
110
|
/>;
|