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.
Files changed (157) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-credentials.md +27 -0
  3. package/docs/admin-deployments.md +201 -0
  4. package/docs/admin-dns-certificates.md +160 -0
  5. package/docs/admin-dns-domains.md +92 -0
  6. package/docs/admin-dns.md +18 -0
  7. package/docs/admin-edge.md +200 -196
  8. package/docs/admin-fleet-configuration.md +98 -2
  9. package/docs/admin-geolocation.md +100 -0
  10. package/docs/admin-identity-directories.md +12 -0
  11. package/docs/admin-maintenance.md +133 -0
  12. package/docs/admin-modals.md +45 -2
  13. package/docs/admin-operational-fleet.md +206 -0
  14. package/docs/admin-presentation.md +51 -0
  15. package/docs/admin-signin.md +12 -2
  16. package/docs/auth-pages.md +18 -6
  17. package/docs/auth.md +16 -2
  18. package/docs/client.md +7 -0
  19. package/docs/collection-select.md +14 -0
  20. package/docs/combobox.md +18 -0
  21. package/docs/popover.md +5 -4
  22. package/package.json +1 -1
  23. package/src/admin/core/OperationsHelp.tsx +10 -0
  24. package/src/admin/core/index.ts +6 -4
  25. package/src/admin/core/route-context.ts +6 -0
  26. package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
  27. package/src/admin/credentials/acme-consumer-key.ts +34 -0
  28. package/src/admin/credentials/group-api-keys.tsx +5 -3
  29. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  30. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  31. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  32. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  33. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  34. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  35. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  36. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  37. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  38. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  39. package/src/admin/deployments/api.ts +158 -0
  40. package/src/admin/deployments/authority-state.ts +9 -0
  41. package/src/admin/deployments/deployment-state.ts +13 -0
  42. package/src/admin/deployments/index.ts +4 -0
  43. package/src/admin/deployments/onboarding-state.ts +11 -0
  44. package/src/admin/deployments/permissions.ts +37 -0
  45. package/src/admin/deployments/receipts.ts +78 -0
  46. package/src/admin/deployments/sanitize.ts +73 -0
  47. package/src/admin/deployments/shared.tsx +39 -0
  48. package/src/admin/deployments/types.ts +238 -0
  49. package/src/admin/deployments/upload.ts +53 -0
  50. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  51. package/src/admin/dns/CertificateDetail.tsx +8 -3
  52. package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
  53. package/src/admin/dns/DomainDetail.tsx +14 -4
  54. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  55. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  56. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  57. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  58. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  59. package/src/admin/dns/DomainsPage.tsx +26 -13
  60. package/src/admin/dns/SharedValidationDialog.tsx +298 -0
  61. package/src/admin/dns/SharedValidationPanel.tsx +76 -0
  62. package/src/admin/dns/api.ts +4 -2
  63. package/src/admin/dns/domain-navigation.ts +16 -0
  64. package/src/admin/dns/registrar-api.ts +13 -0
  65. package/src/admin/dns/shared-validation-api.ts +90 -0
  66. package/src/admin/dns/shared-validation-data.ts +106 -0
  67. package/src/admin/domains/assistant.ts +5 -4
  68. package/src/admin/domains/communications.ts +10 -9
  69. package/src/admin/domains/identity.ts +8 -7
  70. package/src/admin/domains/infrastructure.ts +19 -13
  71. package/src/admin/domains/lazy-page.ts +6 -0
  72. package/src/admin/domains/observability.ts +6 -5
  73. package/src/admin/domains/operations.ts +27 -10
  74. package/src/admin/domains/security.ts +16 -15
  75. package/src/admin/edge/EdgeHelp.tsx +10 -0
  76. package/src/admin/edge/ServingReadiness.tsx +54 -0
  77. package/src/admin/edge/VhostDetail.tsx +145 -139
  78. package/src/admin/edge/VhostWizard.tsx +204 -115
  79. package/src/admin/edge/VhostsPage.tsx +27 -28
  80. package/src/admin/edge/api.ts +24 -10
  81. package/src/admin/edge/models.ts +4 -4
  82. package/src/admin/edge/serving-data.ts +78 -0
  83. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  84. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  85. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  86. package/src/admin/fleet/FleetHelp.tsx +7 -0
  87. package/src/admin/fleet/FleetOverview.tsx +150 -0
  88. package/src/admin/fleet/FleetPage.tsx +11 -0
  89. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  90. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  91. package/src/admin/fleet/access.ts +22 -0
  92. package/src/admin/fleet/api.ts +77 -0
  93. package/src/admin/fleet/data.ts +148 -0
  94. package/src/admin/fleet/index.ts +8 -0
  95. package/src/admin/fleet/operations.ts +179 -0
  96. package/src/admin/fleet/standalone-data.ts +87 -0
  97. package/src/admin/fleet/types.ts +208 -0
  98. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
  99. package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
  100. package/src/admin/fleet-configuration/data.ts +4 -0
  101. package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
  102. package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
  103. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  104. package/src/admin/geolocation/data.ts +138 -0
  105. package/src/admin/geolocation/index.ts +3 -0
  106. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
  107. package/src/admin/identity/groups/models.ts +2 -0
  108. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  109. package/src/admin/index.ts +8 -0
  110. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  111. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  112. package/src/admin/maintenance/data.ts +486 -0
  113. package/src/admin/maintenance/index.ts +3 -0
  114. package/src/admin/maintenance/operations.ts +208 -0
  115. package/src/admin/public/infrastructure.ts +1 -0
  116. package/src/admin/public/operations.ts +3 -0
  117. package/src/admin/registry.ts +6 -2
  118. package/src/admin/settings/SettingDetail.tsx +7 -2
  119. package/src/admin/settings/SettingEditor.tsx +2 -1
  120. package/src/admin/settings/SettingsPage.tsx +3 -1
  121. package/src/admin/settings/model.ts +1 -0
  122. package/src/admin/signin/SigninPage.tsx +17 -3
  123. package/src/admin/signin/api.ts +2 -0
  124. package/src/charts/MetricsChartView.tsx +4 -11
  125. package/src/charts/MetricsMiniWidget.tsx +3 -15
  126. package/src/client/auth.ts +77 -25
  127. package/src/client/client.ts +6 -0
  128. package/src/client/deployments-mock.ts +375 -0
  129. package/src/client/domain-operations-mock.ts +37 -0
  130. package/src/client/fleet-mock.ts +73 -1
  131. package/src/client/geo-provider-mock.ts +82 -0
  132. package/src/client/index.ts +2 -2
  133. package/src/client/maintenance-mock.ts +285 -0
  134. package/src/client/mock.ts +280 -11
  135. package/src/client/operational-fleet-mock.ts +279 -0
  136. package/src/client/operations-mock.ts +85 -0
  137. package/src/client/serving-mock.ts +22 -0
  138. package/src/styles/components/admin-deployments.css +35 -0
  139. package/src/styles/components/admin-dns-certificates.css +33 -0
  140. package/src/styles/components/admin-domains.css +12 -0
  141. package/src/styles/components/admin-edge.css +125 -1
  142. package/src/styles/components/admin-geolocation.css +40 -0
  143. package/src/styles/components/admin-maintenance.css +127 -0
  144. package/src/styles/components/admin-operational-fleet.css +38 -0
  145. package/src/styles/components/admin-operations.css +9 -0
  146. package/src/styles/components/admin-signin.css +2 -0
  147. package/src/styles/components/combobox.css +12 -2
  148. package/src/styles/components/fleet-page.css +9 -0
  149. package/src/styles/index.css +7 -0
  150. package/src/ui/CollectionSelect.tsx +30 -8
  151. package/src/ui/ComboBox.tsx +39 -8
  152. package/src/ui/FilterBar.tsx +3 -5
  153. package/src/ui/ModelTable.tsx +2 -8
  154. package/src/ui/Popover.tsx +15 -7
  155. package/src/ui/date/TimePicker.tsx +4 -14
  156. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  157. package/src/ui/schema-form-core.tsx +3 -5
@@ -0,0 +1,49 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { modal } from '../../ui/modal';
3
+ import { assertOwner } from './permissions';
4
+ import { Tabs } from '../../ui/Tabs';
5
+ import { downloadBlob, useMe } from '../../client/runtime';
6
+ import { getWorkflow, getKeyStatus, resolveAppAuthority, getWebApp } from './api';
7
+ import { isInteractive } from './permissions';
8
+ import { useDeploymentOwner } from './useDeploymentObservation';
9
+ import { ErrorNotice, useWrite } from './shared';
10
+ import type { WebAppObject } from './types';
11
+ export function WebAppDeploySetup({ app, workflowAllowed, releaseAllowed, keyAllowed, deploymentKey, onKey, onChanged }: {
12
+ app: WebAppObject;
13
+ workflowAllowed: boolean;
14
+ releaseAllowed: boolean;
15
+ keyAllowed: boolean;
16
+ deploymentKey?: { linked: boolean; active: boolean };
17
+ onKey: (action: 'mint' | 'rotate' | 'revoke') => void;
18
+ onChanged?: () => void;
19
+ }) {
20
+ const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal);
21
+ const [key, setKey] = useState(deploymentKey), [keyError, setKeyError] = useState('');
22
+ useEffect(() => { setKey(deploymentKey); setKeyError(''); }, [deploymentKey?.linked, deploymentKey?.active, s.key]);
23
+ const refreshKey = () => w.run(async () => { const next = await getKeyStatus(s.owner, app.id, app.group.id, s.signal); setKey(next); setKeyError(''); });
24
+ useEffect(() => { if (!keyAllowed || deploymentKey) return; let disposed = false; void getKeyStatus(s.owner, app.id, app.group.id, s.signal).then(next => { if (!disposed) setKey(next); }).catch(() => { if (!disposed && !s.signal.aborted) setKeyError('Key status unavailable. Refresh before choosing a key action.'); }); return () => { disposed = true; }; }, [s.key, s.signal, keyAllowed, app.id, app.group.id, !!deploymentKey]);
25
+ const generate = () => w.run(async () => {
26
+ const object = await getWebApp(s.owner, app.id, s.signal), authority = await resolveAppAuthority(s.owner, me.data, object, 'workflow', s.signal);
27
+ if (!isInteractive() || !authority.workflow) throw new Error('Workflow authority unavailable');
28
+ const workflow = await getWorkflow(s.owner, app.id, object.group.id, s.signal);
29
+ assertOwner(s.owner, s.signal);
30
+ void modal.open(close => <WorkflowDialog workflow={workflow} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' });
31
+ });
32
+ const loadUpload = async () => { const { WebAppUploadPanel } = await import('./WebAppUploadPanel'); assertOwner(s.owner, s.signal); let busy = false; await modal.open(close => <UploadDialog app={app} Upload={WebAppUploadPanel} onChanged={onChanged} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy, size: 'lg' }); };
33
+ const showIntegration = () => { void modal.open(close => <div className="modal-pad admin-deployments"><h2 className="modal-title">Other CI integration</h2><ol className="deployment-instructions"><li>Store this app’s deployment key in your CI secret store.</li><li>Register <code>POST /api/edge/release</code> with webapp <code>{app.id}</code>, a unique version and a SHA-256/size manifest.</li><li>PUT each file to its returned signed URL using the exact checksum header. Send no API bearer credentials or cookies to storage.</li><li>Call <code>POST /api/edge/release/complete</code> with the release ID, then read <code>/api/edge/release/deployment/ID</code> until terminal.</li></ol><p>Only <code>live</code> is a successful rollout. Registration and completion alone are not proof.</p><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>, { signal: s.signal, size: 'md' }); };
34
+ const keyControls = <section className="deployment-key-setup"><h3>Deployment key</h3><p className="dim">{key ? key.active ? 'Active key linked. Its value cannot be retrieved.' : key.linked ? 'The linked key is inactive. Rotate it to reconnect CI.' : 'Create a key for this app, then save it as a CI secret.' : 'Key status not observed.'}</p>{keyAllowed && <div className="deployment-actions">{key && (!key.linked ? <button className="btn" disabled={w.busy} onClick={() => onKey('mint')}>Create deployment key…</button> : <><button className="btn" disabled={w.busy} onClick={() => onKey('rotate')}>Rotate key…</button>{key.active && <button className="btn" disabled={w.busy} onClick={() => onKey('revoke')}>Revoke key…</button>}</>)}<button className="btn" disabled={w.busy} onClick={() => void refreshKey()}>Refresh key status</button></div>}{keyError && <ErrorNotice>{keyError}</ErrorNotice>}</section>;
35
+ return <div className="deployment-setup"><Tabs ariaLabel="Deployment method" items={[
36
+ { key: 'github', label: 'GitHub', panel: <div className="deployment-stack"><p>Deploy automatically when you push to your repository.</p><ol className="deployment-instructions"><li>Save the generated workflow in your repository.</li><li>Save the deployment key as the Actions secret <code>MOJO_DEPLOY_KEY</code>.</li></ol><div><button className="btn btn-primary" disabled={!workflowAllowed || w.busy} onClick={() => void generate()}>View GitHub workflow</button></div>{keyControls}</div> },
37
+ { key: 'upload', label: 'Upload', panel: <div className="deployment-stack"><p>Deploy an already built site from a folder or selected files.</p><div><button className="btn btn-primary" disabled={!releaseAllowed} onClick={() => void loadUpload()}>Choose built artifact</button></div></div> },
38
+ { key: 'other-ci', label: 'Other CI', panel: <div className="deployment-stack"><p>Connect another CI service using this app’s deployment key and release API.</p><div><button className="btn" onClick={showIntegration}>View integration steps</button></div>{keyControls}</div> },
39
+ ]}/>{w.error && <ErrorNotice>{w.error}</ErrorNotice>}</div>;
40
+ }
41
+ function WorkflowDialog({ workflow, onClose }: { workflow: { filename: string; yaml: string }; onClose: () => void }) {
42
+ const [message, setMessage] = useState('');
43
+ return <div className="modal-pad admin-deployments"><h2 className="modal-title">GitHub workflow</h2><p>Save this file at <code>{workflow.filename}</code>.</p><pre className="deployment-code">{workflow.yaml}</pre>{message && <p role="status">{message}</p>}<div className="modal-actions"><button className="btn" onClick={onClose}>Close</button><button className="btn" onClick={() => void navigator.clipboard.writeText(workflow.yaml).then(() => setMessage('Workflow copied')).catch(() => setMessage('Clipboard unavailable; download the workflow'))}>Copy workflow</button><button className="btn btn-primary" onClick={() => downloadBlob(new Blob([workflow.yaml], { type: 'text/yaml' }), 'deploy-webapp.yml')}>Download workflow</button></div></div>;
44
+ }
45
+
46
+ function UploadDialog({ app, Upload, onClose, onBusy, onChanged }: { app: WebAppObject; Upload: typeof import('./WebAppUploadPanel').WebAppUploadPanel; onClose: () => void; onBusy: (value: boolean) => void; onChanged?: () => void }) {
47
+ const [busy, setBusy] = useState(false);
48
+ return <div className="modal-pad admin-deployments"><header className="deployment-dialog-header"><h2 className="modal-title">Upload built files · {app.display_name || app.slug}</h2><button type="button" className="btn btn-icon" aria-label="Close upload" disabled={busy} onClick={onClose}><i className="bi bi-x-lg" aria-hidden="true"/></button></header><Upload app={app} onChanged={onChanged} onBusy={value => { setBusy(value); onBusy(value); }}/></div>;
49
+ }
@@ -0,0 +1,59 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useMe } from '../../client/runtime';
3
+ import { Tabs } from '../../ui/Tabs';
4
+ import { datetime } from '../../ui/format';
5
+ import { getAttempt, getDeployment, resolveAppAuthority } from './api';
6
+ import { assertOwner, canReadWebApps, isInteractive } from './permissions';
7
+ import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
8
+ import { DeploymentHelp, ErrorNotice, Facts, Status, useCloseOnOwnerChange } from './shared';
9
+ import type { Attempt, DeploymentStatus, WebAppObject } from './types';
10
+
11
+ const terminal = (status: string) => ['live', 'failed', 'rolled_back', 'superseded'].includes(status);
12
+ const active = (status: string) => ['requested', 'queued', 'deploying', 'canary', 'fleet', 'verifying'].includes(status);
13
+ interface Evidence { attempt: Attempt; rollout: DeploymentStatus | null }
14
+
15
+ /** Ordinary CI attempts are inspectable through viewer-safe model history. */
16
+ export function WebAppDeploymentDetail({ app, attempt, detailedAllowed, onClose, onChanged }: {
17
+ app: WebAppObject;
18
+ attempt: Attempt;
19
+ detailedAllowed: boolean;
20
+ onClose: () => void;
21
+ onChanged: () => void;
22
+ }) {
23
+ const s = useDeploymentOwner(), me = useMe(), notified = useRef('');
24
+ const admitted = canReadWebApps(me.data) && isInteractive();
25
+ useCloseOnOwnerChange(s.key, onClose);
26
+ const observation = useDeploymentObservation<Evidence>(s.owner, admitted ? `${app.id}:${attempt.id}:${detailedAllowed}` : null, async signal => {
27
+ const current = await getAttempt(s.owner, attempt.id, app.id, signal);
28
+ let rollout: DeploymentStatus | null = null;
29
+ if (detailedAllowed && current.release) {
30
+ const authority = await resolveAppAuthority(s.owner, me.data, app, 'release', signal);
31
+ if (authority.release) rollout = await getDeployment(s.owner, current.id, app.id, current.release.id, signal);
32
+ }
33
+ assertOwner(s.owner, signal);
34
+ return { attempt: current, rollout };
35
+ }, evidence => terminal(evidence.attempt.status), evidence => active(evidence.attempt.status));
36
+ const current = observation.value?.attempt ?? attempt, rollout = observation.value?.rollout;
37
+ useEffect(() => {
38
+ if (observation.state !== 'terminal' || !observation.value) return;
39
+ const key = `${s.key}:${observation.value.attempt.id}:${observation.value.attempt.status}`;
40
+ if (notified.current !== key) { notified.current = key; onChanged(); }
41
+ }, [observation.state, observation.value, s.key, onChanged]);
42
+ if (!admitted) return <div className="modal-pad admin-deployments"><ErrorNotice>Deployment read authority is unavailable.</ErrorNotice><button className="btn" onClick={onClose}>Close</button></div>;
43
+ const overview = <div>
44
+ <Facts rows={[["Attempted version", current.release?.version || 'Not reported'], ["Outcome", <Status value={current.status}/>], ["Started", current.started || current.created ? datetime(current.started || current.created) : 'Not reported'], ["Finished", current.finished ? datetime(current.finished) : 'Not reported']]}/>
45
+ {current.detail && <p>{current.detail}</p>}
46
+ <p className="dim"><DeploymentHelp label="deployment outcome">Only live is a successful rollout. Rolled back, failed and superseded attempts are unsuccessful. The app's desired version is a separate saved pointer, and nodes can be mixed after a failed rollout.</DeploymentHelp> {observation.observedAt ? `Observed ${new Date(observation.observedAt).toLocaleTimeString()}` : 'Refreshing saved evidence…'}</p>
47
+ {observation.state === 'observing' && <p role="status">Observing deployment progress…</p>}
48
+ {observation.state === 'error' && <ErrorNotice>Latest observation unavailable. The displayed evidence may be stale; the outcome is unconfirmed.</ErrorNotice>}
49
+ {observation.state === 'stopped' && <p role="status" className="text-warn">Automatic observation stopped. Refresh only reads this deployment.</p>}
50
+ </div>;
51
+ const nodePanel = rollout && (rollout.targets.length > 0 || rollout.rollback_targets.length > 0) ? <div>{[['Deployment nodes', rollout.targets], ['Rollback nodes', rollout.rollback_targets]].map(([title, nodes]) => {
52
+ const entries = nodes as DeploymentStatus['targets'];
53
+ return <section key={String(title)}><h3>{String(title)}</h3>{entries.length ? entries.map((node, index) => <div className="deployment-evidence" key={`${node.runner}:${index}`}><strong>{node.runner || 'Unnamed node'}</strong><p><Status value={node.status}/></p>{node.detail && <p>{node.detail}</p>}{node.sha && <p className="mono">{node.sha}</p>}</div>) : <p className="dim">No node evidence was returned.</p>}</section>;
54
+ })}</div> : null;
55
+ return <div className="modal-pad admin-deployments deployment-dialog"><header className="deployment-dialog-header"><div className="deployment-dialog-heading"><h2 className="modal-title">Deployment {current.release?.version || `#${current.id}`}</h2><p className="dim">{app.display_name || app.slug}</p></div><button className="btn" aria-label="Close deployment" onClick={onClose}><i className="bi bi-x-lg" aria-hidden="true"/></button></header>
56
+ {nodePanel ? <Tabs ariaLabel="Deployment evidence" items={[{ key: 'overview', label: 'Overview', panel: overview }, { key: 'nodes', label: 'Nodes', panel: nodePanel }]}/> : overview}
57
+ <div className="modal-actions"><button className="btn" onClick={onClose}>Close</button><button className="btn" disabled={observation.state === 'observing'} onClick={observation.refresh}>Refresh deployment</button></div>
58
+ </div>;
59
+ }
@@ -0,0 +1,104 @@
1
+ import { createContext, lazy, Suspense, useContext, useEffect, useState } from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { useMe } from '../../client/runtime';
5
+ import { modal } from '../../ui/modal';
6
+ import { Tabs } from '../../ui/Tabs';
7
+ import { datetime } from '../../ui/format';
8
+ import { ModelTable, type ModelTableQueryAdapterProps } from '../../ui/ModelTable';
9
+ import { backendGlobalCan } from '../fleet/access';
10
+ import { getAttempt, getDeployment, getHealth, getHistory, getMember, getSummary, getWebApp } from './api';
11
+ import { assertOwner, canReadWebApps, isInteractive, webAppAuthority, type DeploymentOwner } from './permissions';
12
+ import { rollbackEligible } from './deployment-state';
13
+ import { useDeploymentOwner } from './useDeploymentObservation';
14
+ import { DeploymentHelp, ErrorNotice, Facts, Status, useCloseOnOwnerChange } from './shared';
15
+ import { useAppActionsRefused } from './authority-state';
16
+ import { appUnsettled, receiptsFor, settleReceipt } from './receipts';
17
+ import type { Attempt, Release, DeploymentsDestinations } from './types';
18
+
19
+ const DeploySetup = lazy(() => import('./WebAppDeploySetup').then(module => ({ default: module.WebAppDeploySetup })));
20
+
21
+ export function WebAppDetail({ id, onClose, destinations }: {
22
+ id: number;
23
+ onClose: () => void;
24
+ destinations?: DeploymentsDestinations;
25
+ }) {
26
+ const me = useMe(), s = useDeploymentOwner(), client = useQueryClient();
27
+ const [health, setHealth] = useState(''), [healthBusy, setHealthBusy] = useState(false);
28
+ const read = canReadWebApps(me.data) && isInteractive();
29
+ useCloseOnOwnerChange(s.key, onClose);
30
+ const summary = useQuery({ queryKey: ['deployments', s.key, id, 'summary'], queryFn: ({ signal }) => getSummary(s.owner, id, signal), enabled: read, retry: false });
31
+ const object = useQuery({ queryKey: ['deployments', s.key, id, 'default'], queryFn: ({ signal }) => getWebApp(s.owner, id, signal), enabled: read, retry: false });
32
+ const globalAuthority = backendGlobalCan(me.data, ['manage_webapp']) && backendGlobalCan(me.data, ['manage_dns', 'security']);
33
+ const member = useQuery({ queryKey: ['deployments', s.key, id, 'member', object.data?.group.id], queryFn: ({ signal }) => getMember(s.owner, object.data!.group.id, signal), enabled: read && !!object.data && !globalAuthority, retry: false });
34
+ const actionsRefused = useAppActionsRefused(s.owner, id);
35
+ const authority = object.data ? webAppAuthority(me.data, object.data, member.data) : { read: false, custom: false, release: false, workflow: false };
36
+ if (actionsRefused) { authority.custom = false; authority.release = false; authority.workflow = false; }
37
+ const app = summary.data, group = object.data?.group.id ?? 0;
38
+ const latest = useQuery({ queryKey: ['deployments', s.key, id, 'latest-attempt', app?.latest_deployment?.id], queryFn: ({ signal }) => getAttempt(s.owner, app!.latest_deployment!.id, id, signal), enabled: read && !!app?.latest_deployment && !app.latest_deployment.release, retry: false });
39
+ const refresh = async () => { await client.invalidateQueries({ queryKey: ['deployments', s.key] }); };
40
+ useEffect(() => {
41
+ const receipt = appUnsettled(s.owner, id);
42
+ if (authority.release && receipt?.deployment && receipt.release) void getDeployment(s.owner, receipt.deployment, id, receipt.release, s.signal).then(result => { if (result.terminal) settleReceipt(receipt); }).catch(() => {});
43
+ }, [s.key, id, summary.dataUpdatedAt, authority.release]);
44
+ const inspectAttempt = async (attempt: Attempt) => {
45
+ if (!object.data) return;
46
+ const { WebAppDeploymentDetail } = await import('./WebAppDeploymentDetail'); assertOwner(s.owner, s.signal);
47
+ await modal.open(close => <WebAppDeploymentDetail app={object.data!} attempt={attempt} detailedAllowed={authority.release} onChanged={() => void refresh()} onClose={() => close(null)}/>, { signal: s.signal, size: 'lg' });
48
+ };
49
+ const launchRollback = async (release: Release) => {
50
+ if (!object.data || !authority.custom) return;
51
+ const { WebAppRollbackDialog } = await import('./WebAppRollbackDialog'); assertOwner(s.owner, s.signal); let busy = false;
52
+ await modal.open(close => <WebAppRollbackDialog app={object.data!} release={release} onChanged={() => void refresh()} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy, size: 'lg' });
53
+ };
54
+ const launchKey = async (action: 'mint' | 'rotate' | 'revoke') => {
55
+ if (!object.data || !authority.custom) return;
56
+ const { WebAppKeyDialog } = await import('./WebAppKeyDialog'); assertOwner(s.owner, s.signal); let busy = false;
57
+ await modal.open(close => <WebAppKeyDialog app={object.data!} action={action} onChanged={() => void refresh()} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy });
58
+ };
59
+ const edit = async () => {
60
+ if (!object.data || !authority.release) return;
61
+ const { WebAppEditDialog } = await import('./WebAppEditDialog'); assertOwner(s.owner, s.signal); let busy = false;
62
+ await modal.open(close => <WebAppEditDialog app={object.data!} onChanged={() => void refresh()} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy, size: 'md' });
63
+ };
64
+ const resume = async () => {
65
+ const { WebAppOnboardingDialog } = await import('./WebAppOnboardingDialog'); assertOwner(s.owner, s.signal); let busy = false;
66
+ await modal.open(close => <WebAppOnboardingDialog destinations={destinations} resume={receiptsFor(s.owner).find(receipt => receipt.kind === 'create' && receipt.app === id)} onChanged={() => void refresh()} onClose={() => close(null)} onBusy={value => { busy = value; }}/>, { signal: s.signal, canDismiss: () => !busy, size: 'lg' });
67
+ };
68
+ const checkHealth = async () => {
69
+ if (healthBusy || !group) return;
70
+ setHealthBusy(true);
71
+ try { const result = await getHealth(s.owner, id, group, s.signal); assertOwner(s.owner, s.signal); setHealth(`${result.status}: ${result.detail}${result.checked ? ` · ${datetime(result.checked)}` : ''}`); }
72
+ catch { if (!s.signal.aborted) setHealth('Observation unavailable. Reachability is unconfirmed.'); }
73
+ finally { if (!s.signal.aborted) setHealthBusy(false); }
74
+ };
75
+ if (!read || summary.isError || object.isError) return <div className="modal-pad admin-deployments"><h2 className="modal-title">WebApp unavailable</h2><ErrorNotice>App evidence could not be read. No app actions are available.</ErrorNotice><div className="modal-actions"><button className="btn" onClick={onClose}>Close</button>{read && <button className="btn" onClick={() => void refresh()}>Refresh app</button>}</div></div>;
76
+ if (!app || !object.data) return <div className="modal-pad admin-deployments"><p role="status">Loading WebApp…</p><button className="btn" onClick={onClose}>Close</button></div>;
77
+ const attempted = app.latest_deployment?.release ?? latest.data?.release;
78
+ const historyContext = { owner: s.owner, scopeKey: s.key, app: id };
79
+ const setupIncomplete = app.onboarding.status !== 'succeeded', pending = appUnsettled(s.owner, id);
80
+ return <div className="modal-pad admin-deployments deployment-dialog">
81
+ <header className="deployment-dialog-header"><div className="deployment-dialog-heading"><h2 className="modal-title">{app.webapp.display_name || app.webapp.slug}</h2><p className="dim">{app.address?.hostname || 'No public address'}</p></div><div className="deployment-row-actions"><button className="btn" disabled={summary.isFetching || object.isFetching} onClick={() => void refresh()}>Refresh app</button><button className="btn" aria-label="Close WebApp" onClick={onClose}><i className="bi bi-x-lg" aria-hidden="true"/></button></div></header>
82
+ <Tabs ariaLabel="WebApp tasks" items={[
83
+ { key: 'overview', label: 'Overview', panel: <div>
84
+ <div className="deployment-summary-grid"><section><h3>Desired version <DeploymentHelp label="desired version">The saved release pointer describes desired content. It does not prove that every node is serving it.</DeploymentHelp></h3><strong className="mono">{app.current_release?.version || 'No release'}</strong></section><section><h3>Latest deployment</h3>{app.latest_deployment ? <><strong className="mono">{attempted?.version || (latest.isError ? 'Version unavailable' : 'Loading version…')}</strong><p><Status value={app.latest_deployment.status}/></p><button className="btn" onClick={() => void inspectAttempt(latest.data ?? app.latest_deployment!)}>Inspect deployment</button></> : <p className="dim">No deployment attempts.</p>}</section></div>
85
+ {setupIncomplete && <section className="deployment-confirm"><h3>Setup {app.onboarding.status}</h3>{receiptsFor(s.owner).some(receipt => receipt.kind === 'create' && receipt.app === id) ? <button className="btn" onClick={() => void resume()}>Resume saved setup</button> : <p className="dim">No matching saved setup receipt is available in this browser. Existing resources are preserved.</p>}</section>}
86
+ {pending && <p role="status" className="text-warn">{pending.kind === 'create' ? 'Finish or cancel saved setup before starting another change.' : 'A submitted operation has an unconfirmed outcome. Inspect its deployment before starting another change.'}</p>}
87
+ <Facts rows={[["Environment", app.webapp.environment], ["HTTPS certificate", app.address?.certificate?.status || 'Not configured']]}/>
88
+ </div> },
89
+ { key: 'deployments', label: 'Deployments', panel: <HistoryContext.Provider value={historyContext}><ModelTable<Release | Attempt> endpoint="/api/edge/deployment" title="Deployment attempts" paramsMode="local" searchable={false} defaultParams={{ size: 15 }} fixedParams={{ webapp: id, graph: 'list' }} queryAdapter={AttemptRows} onRowClick={row => void inspectAttempt(row as Attempt)} columns={[{ key: 'release', label: 'Version', render: row => (row as Attempt).release?.version || 'Unobserved' }, { key: 'status', label: 'Outcome', render: row => <Status value={row.status}/> }, { key: 'created', label: 'Started', render: row => datetime(row.created) }]}/></HistoryContext.Provider> },
90
+ { key: 'releases', label: 'Releases', panel: <div className="deployment-release-history"><HistoryContext.Provider value={historyContext}><ModelTable<Release | Attempt> endpoint="/api/edge/release" title="Registered releases" paramsMode="local" searchable={false} defaultParams={{ size: 25 }} fixedParams={{ webapp: id, graph: 'list' }} queryAdapter={ReleaseRows} columns={[{ key: 'version', label: 'Version', render: row => <span className="mono">{(row as Release).version}{row.id === app.current_release?.id && <small className="dim"> · desired</small>}</span> }, { key: 'status', label: 'Verification', render: row => <Status value={row.status}/> }, { key: 'created', label: 'Registered', render: row => datetime(row.created) }, ...(authority.custom ? [{ key: 'action', label: '', render: (row: Release | Attempt) => rollbackEligible(row as Release, app.current_release) ? <button className="btn" disabled={!!appUnsettled(s.owner, id)} onClick={() => void launchRollback(row as Release)}>Roll back</button> : null }] : [])]}/></HistoryContext.Provider></div> },
91
+ ...(authority.workflow || authority.release || authority.custom ? [{ key: 'deploy', label: 'Deploy', panel: <Suspense fallback={<p role="status">Loading deployment methods…</p>}><DeploySetup app={object.data} workflowAllowed={authority.workflow} releaseAllowed={authority.release} keyAllowed={authority.custom} deploymentKey={app.deployment_key} onChanged={() => void refresh()} onKey={action => void launchKey(action)}/></Suspense> }] : []),
92
+ { key: 'settings', label: 'Settings', panel: <div><div className="deployment-toolbar"><h3>App profile</h3>{authority.release && <button className="btn" onClick={() => void edit()}>Edit app</button>}</div><Facts rows={[["Name", app.webapp.display_name], ["Environment", app.webapp.environment], ["Repository", app.webapp.repository || 'Not configured'], ["Branch", app.webapp.deployment_ref], ["Build output", app.webapp.build_output]]}/><h3>Address & HTTPS</h3><Facts rows={[["Address", app.address?.hostname || 'Not configured'], ["Certificate", app.address?.certificate?.status || 'Not configured'], ["Expires", app.address?.certificate?.not_after ? datetime(app.address.certificate.not_after) : 'Unknown']]}/><div className="deployment-row-actions">{(['domains', 'serving'] as const).map(key => { const path = destinations?.[key]; return path && /^\/[a-z0-9/_-]+$/i.test(path) ? <Link key={key} className="btn" to={path} onClick={onClose}>{key === 'domains' ? 'Open Domains' : 'Open Serving'}</Link> : null; })}{authority.read && <button className="btn" disabled={!group || healthBusy} onClick={() => void checkHealth()}>{healthBusy ? 'Checking HTTPS…' : 'Check HTTPS'}</button>}</div>{health && <p role="status">{health}</p>}{member.isError && !globalAuthority && <p className="dim">Workspace action authority could not be confirmed. Read-only app evidence remains available.</p>}</div> },
93
+ ]}/>
94
+ </div>;
95
+ }
96
+
97
+ const HistoryContext = createContext<{ owner: DeploymentOwner; scopeKey: string; app: number } | null>(null);
98
+ function HistoryRows({ params, enabled, children, kind }: { kind: 'release' | 'deployment' } & ModelTableQueryAdapterProps<Release | Attempt>) {
99
+ const context = useContext(HistoryContext)!;
100
+ const query = useQuery({ queryKey: ['deployments', context.scopeKey, context.app, kind, params.start, params.size], queryFn: ({ signal }) => getHistory(context.owner, context.app, kind, Number(params.start) || 0, signal, Number(params.size) || (kind === 'release' ? 25 : 15)), enabled, retry: false });
101
+ return children({ data: query.data ? { rows: query.data.items, count: query.data.count } : undefined, status: query.status, isFetching: query.isFetching, error: query.error, refetch: query.refetch });
102
+ }
103
+ function ReleaseRows(props: ModelTableQueryAdapterProps<Release | Attempt>) { return <HistoryRows {...props} kind="release"/>; }
104
+ function AttemptRows(props: ModelTableQueryAdapterProps<Release | Attempt>) { return <HistoryRows {...props} kind="deployment"/>; }
@@ -0,0 +1,70 @@
1
+ import { useRef, useState } from 'react';
2
+ import { useMe } from '../../client/runtime';
3
+ import { getWebApp, resolveAppAuthority, saveWebAppProfile } from './api';
4
+ import { assertOwner, isInteractive } from './permissions';
5
+ import { makeReceipt, receiptsFor, saveReceipt, settleReceipt } from './receipts';
6
+ import { useDeploymentOwner } from './useDeploymentObservation';
7
+ import { DeploymentHelp, ErrorNotice, useCloseOnOwnerChange, useWrite } from './shared';
8
+ import type { WebAppObject } from './types';
9
+
10
+ type Profile = { display_name: string; environment: string; github_repository: string; deployment_ref: string; build_output: string };
11
+ const profile = (app: WebAppObject): Profile => ({ display_name: app.display_name, environment: app.environment, github_repository: app.repository, deployment_ref: app.deployment_ref, build_output: app.build_output });
12
+
13
+ export function WebAppEditDialog({ app, onClose, onChanged, onBusy }: {
14
+ app: WebAppObject;
15
+ onClose: () => void;
16
+ onChanged: () => void;
17
+ onBusy: (busy: boolean) => void;
18
+ }) {
19
+ const s = useDeploymentOwner(), me = useMe(), write = useWrite(s.owner, s.signal, onBusy);
20
+ const [base, setBase] = useState(() => profile(app)), [draft, setDraft] = useState(() => profile(app)), [notice, setNotice] = useState(''), [reviewedUnknown, setReviewedUnknown] = useState(''), [acknowledgement, setAcknowledgement] = useState('');
21
+ const form = useRef<HTMLFormElement>(null);
22
+ useCloseOnOwnerChange(s.key, onClose);
23
+ const unknownReceipts = () => receiptsFor(s.owner).filter(receipt => receipt.kind === 'profile' && receipt.app === app.id && receipt.submitted && !receipt.settled);
24
+ const unknownSignature = () => JSON.stringify(unknownReceipts().map(receipt => [receipt.key, receipt.operation, receipt.submittedAt ?? 0]));
25
+ const pending = unknownReceipts().length > 0;
26
+ const canAcknowledge = pending && reviewedUnknown === unknownSignature();
27
+ const dirty = JSON.stringify(base) !== JSON.stringify(draft);
28
+ const change = (key: keyof Profile, value: string) => { setNotice(''); setDraft(previous => ({ ...previous, [key]: value })); };
29
+ const reload = () => write.run(async () => {
30
+ const before = unknownSignature(); setReviewedUnknown(''); setAcknowledgement('');
31
+ const current = await getWebApp(s.owner, app.id, s.signal);
32
+ assertOwner(s.owner, s.signal);
33
+ if (current.group.id !== app.group.id) throw new Error('App ownership changed. Reopen the app before editing.');
34
+ setBase(profile(current)); setDraft(profile(current));
35
+ for (const receipt of receiptsFor(s.owner)) if (receipt.kind === 'profile' && receipt.app === app.id && !receipt.submitted && !receipt.settled) settleReceipt(receipt);
36
+ const after = unknownSignature();
37
+ if (unknownReceipts().length && before === after) setReviewedUnknown(after);
38
+ setNotice(unknownReceipts().length ? 'Current settings loaded. This read does not confirm that the earlier save has finished.' : 'Current saved settings loaded. Review them before making another change.'); onChanged();
39
+ });
40
+ const acknowledge = () => write.run(async () => {
41
+ if (!canAcknowledge || acknowledgement !== `${app.slug}/unknown-save` || reviewedUnknown !== unknownSignature()) return;
42
+ for (const receipt of unknownReceipts()) saveReceipt({ ...receipt, settled: true, acknowledged: true });
43
+ setReviewedUnknown(''); setAcknowledgement('');
44
+ setNotice('Unknown outcome acknowledged. The earlier save may still finish. Review and change the fields, then save separately if you wish to continue.'); onChanged();
45
+ });
46
+ const save = () => write.run(async () => {
47
+ if (!form.current?.reportValidity() || !dirty || pending || !isInteractive()) return;
48
+ const current = await getWebApp(s.owner, app.id, s.signal);
49
+ if (current.group.id !== app.group.id) throw new Error('App ownership changed. Reopen the app before editing.');
50
+ const authority = await resolveAppAuthority(s.owner, me.data, current, 'release', s.signal);
51
+ if (!authority.release) throw new Error('App edit authority is unavailable.');
52
+ const fresh = profile(current), patch = { ...draft };
53
+ if (Object.keys(patch).some(key => fresh[key as keyof Profile] !== base[key as keyof Profile])) throw new Error('Saved settings changed. Reload saved settings and review before saving.');
54
+ setReviewedUnknown(''); setAcknowledgement('');
55
+ const receipt = makeReceipt(s.owner, 'profile', `${app.id}:profile`, app.id, 'profile');
56
+ await saveWebAppProfile(s.owner, receipt, current, patch, s.signal);
57
+ const observed = await getWebApp(s.owner, app.id, s.signal); assertOwner(s.owner, s.signal);
58
+ if (observed.group.id !== app.group.id) throw new Error('Saved app ownership changed. Reopen the app.');
59
+ const saved = profile(observed);
60
+ if (Object.entries(patch).some(([key, value]) => saved[key as keyof Profile] !== value)) throw new Error('Saved settings do not match this change. Reload saved settings to review.');
61
+ settleReceipt(receipt); onChanged(); onClose();
62
+ });
63
+ return <form ref={form} className="modal-pad admin-deployments" onSubmit={event => { event.preventDefault(); void save(); }}><h2 className="modal-title">Edit {app.display_name || app.slug}</h2>
64
+ {pending && <p role="status" className="text-warn">A previous save has an unconfirmed outcome. Reload saved settings to review current values. No save will be repeated automatically.</p>}
65
+ <fieldset disabled={write.busy || pending} className="deployment-fields"><label>Name<input maxLength={120} value={draft.display_name} onChange={event => change('display_name', event.target.value)}/></label><label>Environment<select value={draft.environment} onChange={event => change('environment', event.target.value)}>{!['production', 'staging', 'preview', 'development'].includes(draft.environment) && <option value={draft.environment} disabled>{draft.environment || 'Unknown environment'}</option>}{['production', 'staging', 'preview', 'development'].map(value => <option value={value} key={value}>{value[0]!.toUpperCase() + value.slice(1)}</option>)}</select></label><label>GitHub repository<input maxLength={255} placeholder="owner/repository" value={draft.github_repository} onChange={event => change('github_repository', event.target.value)}/></label><div className="deployment-form"><label>Branch<input required maxLength={255} value={draft.deployment_ref} onChange={event => change('deployment_ref', event.target.value)}/></label><label><span className="field-label">Build output <DeploymentHelp label="build output">The directory CI builds, such as dist. Changing this profile does not deploy a release.</DeploymentHelp></span><input required maxLength={255} value={draft.build_output} onChange={event => change('build_output', event.target.value)}/></label></div></fieldset>
66
+ {canAcknowledge && <section className="deployment-confirm"><p className="text-warn">The earlier save may still finish and overwrite a new change.</p><label>Type {app.slug}/unknown-save<input aria-label="Unknown save acknowledgement" value={acknowledgement} disabled={write.busy} onChange={event => setAcknowledgement(event.target.value)}/></label><button type="button" className="btn" disabled={write.busy || acknowledgement !== `${app.slug}/unknown-save`} onClick={() => void acknowledge()}>Acknowledge unknown save</button></section>}
67
+ {write.error && <ErrorNotice>{write.error}</ErrorNotice>}{notice && <p role="status">{notice}</p>}
68
+ <div className="modal-actions"><button type="button" className="btn" disabled={write.busy} onClick={onClose}>Cancel</button><button type="button" className="btn" disabled={write.busy} onClick={() => void reload()}>Reload saved settings</button><button type="submit" className="btn btn-primary" disabled={write.busy || pending || !dirty}>Save changes</button></div>
69
+ </form>;
70
+ }
@@ -0,0 +1,25 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useMe } from '../../client/runtime';
3
+ import { changeKey, getKeyStatus, resolveAppAuthority, getWebApp } from './api';
4
+ import { isInteractive } from './permissions';
5
+ import { makeReceipt, settleReceipt } from './receipts';
6
+ import { useDeploymentOwner } from './useDeploymentObservation';
7
+ import { ErrorNotice, useWrite } from './shared';
8
+ import type { WebAppObject } from './types';
9
+ export function WebAppKeyDialog({ app, action, onClose, onBusy, onChanged }: {
10
+ app: WebAppObject;
11
+ action: 'mint' | 'rotate' | 'revoke';
12
+ onClose: () => void;
13
+ onBusy: (v: boolean) => void;
14
+ onChanged: () => void;
15
+ }) {
16
+ const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal, onBusy), secret = useRef<string | null>(null);
17
+ const [reveal, setReveal] = useState(false), [done, setDone] = useState(false), [unavailable, setUnavailable] = useState(false), [confirmation, setConfirmation] = useState(''), [copied, setCopied] = useState(false);
18
+ useEffect(() => () => { secret.current = null; }, [s.key]);
19
+ const execute = () => w.run(async () => { const object = await getWebApp(s.owner, app.id, s.signal); if (object.group.id !== app.group.id)
20
+ throw new Error('App ownership changed'); const authority = await resolveAppAuthority(s.owner, me.data, object, 'custom', s.signal); if (!isInteractive() || !authority.custom)
21
+ throw new Error('Exact app credential authority is unavailable'); await getKeyStatus(s.owner, app.id, app.group.id, s.signal); const r = makeReceipt(s.owner, 'key', `${app.id}:${action}`, app.id, action); const result = await changeKey(s.owner, r, app.id, app.group.id, action, s.signal); secret.current = result.token; setUnavailable(action !== 'revoke' && !result.token); setDone(true); settleReceipt(r); onChanged(); });
22
+ const close = () => { if (w.busy)
23
+ return; secret.current = null; onClose(); };
24
+ return <div className="modal-pad admin-deployments"><h2 className="modal-title">{action === 'mint' ? 'Create' : action === 'rotate' ? 'Rotate' : 'Revoke'} MOJO_DEPLOY_KEY</h2><p className="modal-message">{app.display_name || app.slug}: {action === 'revoke' ? 'Future CI deployments lose this credential. Existing serving content stays.' : 'The key is delivered once. Store it in your CI secrets as MOJO_DEPLOY_KEY. Rotation requires updating CI before its next deployment.'}</p>{!done ? <label>Type {app.slug} to confirm<input value={confirmation} disabled={w.busy} onChange={e => setConfirmation(e.target.value)}/></label> : unavailable ? <p role="status">Secret unavailable. This receipt was already delivered or its response was lost. No token can be recovered from metadata. Close and explicitly start a new rotation.</p> : action === 'revoke' ? <p role="status">Revocation receipt observed. The serving release remains independent.</p> : <><p>The value clears when this dialog closes.</p><input aria-label="One-time deployment key" type={reveal ? 'text' : 'password'} value={secret.current ?? ''} readOnly autoComplete="off"/><button className="btn" onClick={() => setReveal(v => !v)}>{reveal ? 'Hide' : 'Reveal once'}</button><button className="btn" onClick={() => void navigator.clipboard.writeText(secret.current ?? '').then(() => setCopied(true)).catch(() => setCopied(false))}>{copied ? 'Copied' : 'Copy key'}</button></>}{w.error && <ErrorNotice>{w.error}</ErrorNotice>}<div className="modal-actions"><button className="btn" disabled={w.busy} onClick={close}>{done ? 'I saved it · Close' : 'Cancel'}</button>{!done && <button className="btn btn-primary" disabled={w.busy || confirmation !== app.slug} onClick={() => void execute()}>Confirm {action}</button>}</div></div>;
25
+ }
@@ -0,0 +1,217 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { Tabs } from '../../ui/Tabs';
4
+ import { modal } from '../../ui/modal';
5
+ import { Link } from 'react-router-dom';
6
+ import { getBootstrap, getOperation, getOptions, precheckAddress, createApp, chooseOnboarding, cancelOnboarding } from './api';
7
+ import { canReadBootstrap, canReadWebApps, canOnboard, isInteractive } from './permissions';
8
+ import { useMe } from '../../client/runtime';
9
+ import { useDeploymentObservation, useDeploymentOwner } from './useDeploymentObservation';
10
+ import { managedChoice, onboardingGeneration, onboardingPollable, onboardingTerminal } from './onboarding-state';
11
+ import { makeReceipt, receiptsFor, saveReceipt, settleReceipt, type ActionReceipt } from './receipts';
12
+ import { ErrorNotice, Facts, Status, useWrite } from './shared';
13
+ import type { AddressPrecheck, Bootstrap, DeploymentsDestinations, OnboardingOperation } from './types';
14
+
15
+ export function WebAppOnboardingDialog({ bootstrap, resume, destinations, onClose, onBusy, onChanged }: {
16
+ bootstrap?: Bootstrap;
17
+ resume?: ActionReceipt;
18
+ destinations?: DeploymentsDestinations;
19
+ onClose: () => void;
20
+ onBusy: (v: boolean) => void;
21
+ onChanged?: () => void;
22
+ }) {
23
+ const s = useDeploymentOwner(), me = useMe(), w = useWrite(s.owner, s.signal, onBusy);
24
+ const initial = resume ?? receiptsFor(s.owner).find(r => r.kind === 'create' && !r.settled);
25
+ const [receipt, setReceipt] = useState<ActionReceipt | undefined>(initial);
26
+ const [createTab, setCreateTab] = useState('app');
27
+ const [operation, setOperation] = useState<OnboardingOperation | null>(null);
28
+ const [workspace, setWorkspace] = useState(String(initial?.profile?.group ?? bootstrap?.groups[0]?.id ?? 'new'));
29
+ const [name, setName] = useState(initial?.profile?.display_name ?? ''), [slug, setSlug] = useState(initial?.profile?.slug ?? '');
30
+ const [address, setAddress] = useState(''), [checked, setChecked] = useState<AddressPrecheck | null>(null);
31
+ const [repository, setRepository] = useState(initial?.profile?.github_repository ?? '');
32
+ const [ref, setRef] = useState(initial?.profile?.deployment_ref ?? 'main'), [output, setOutput] = useState(initial?.profile?.build_output ?? 'dist');
33
+ const [githubMode, setGithubMode] = useState<'skip' | 'verify' | 'attest'>('skip');
34
+ const [environment, setEnvironment] = useState(initial?.profile?.environment ?? 'production');
35
+ const intent = workspace === 'new' ? { group_intent: 'new' as const } : { group: Number(workspace) };
36
+ const eligible = !!bootstrap && (workspace === 'new' ? bootstrap.can_create_webapp_group : bootstrap.groups.some(g => g.id === Number(workspace)));
37
+ const observer = useDeploymentObservation(s.owner, receipt?.operation ?? null,
38
+ signal => getOperation(s.owner, receipt!.operation, signal), onboardingTerminal, onboardingPollable);
39
+ const latest = observer.value ?? operation;
40
+ const generation = latest ? onboardingGeneration(latest) : '';
41
+ const choiceSent = useRef(new Set<string>());
42
+ // The created operation, not a bootstrap guess or local group intent, owns continuation scope.
43
+ const resolvedIntent = latest?.group.id ? { group: latest.group.id } : intent;
44
+ const options = useQuery({
45
+ queryKey: ['deployments', s.key, 'onboarding-options', latest?.group.id ?? workspace],
46
+ queryFn: ({ signal }) => getOptions(s.owner, resolvedIntent, signal),
47
+ enabled: isInteractive() && (receipt ? !!latest?.group.id : eligible), retry: false,
48
+ });
49
+ const acceptOperation = (value: OnboardingOperation, current: ActionReceipt) => {
50
+ if (current.onboardingGroup && current.onboardingGroup !== value.group.id)
51
+ throw new Error('The operation workspace identity changed. Observe the saved operation again.');
52
+ const next = saveReceipt({ ...current, onboardingGroup: value.group.id,
53
+ app: value.resources.webapp ?? current.app, settled: onboardingTerminal(value) });
54
+ setReceipt(next); setOperation(value);
55
+ };
56
+ useEffect(() => {
57
+ if (latest && receipt && (!receipt.onboardingGroup || receipt.app !== latest.resources.webapp || receipt.settled !== onboardingTerminal(latest)))
58
+ acceptOperation(latest, receipt);
59
+ }, [generation, latest?.status]);
60
+ const changed = useRef('');
61
+ useEffect(() => {
62
+ if (!latest?.resources.webapp) return;
63
+ const next = `${latest.resources.webapp}:${onboardingTerminal(latest) ? latest.status : 'created'}`;
64
+ if (changed.current !== next) { changed.current = next; onChanged?.(); }
65
+ }, [latest?.resources.webapp, latest?.status, onChanged]);
66
+ const setupReady = !!options.data?.destination && !!options.data.buckets.length;
67
+ const newIntent = !receipt && workspace === 'new';
68
+ const addressReady = checked?.verdict === 'ready' && !!checked.domain;
69
+ const savedAddress = receipt?.address;
70
+ const addressChoiceReady = addressReady || (!address.trim() && !!savedAddress);
71
+ const savedHostname = savedAddress && options.data?.apps_domain?.id === savedAddress.domain ? `${savedAddress.label ? `${savedAddress.label}.` : ''}${options.data.apps_domain.name}` : '';
72
+ const validSlug = /^[a-z0-9][a-z0-9-]{0,63}$/.test(slug);
73
+ const creationReady = setupReady && !!name.trim() && validSlug && (newIntent || addressReady);
74
+ const reviewApp = createTab === 'build' && !creationReady;
75
+ const domainsPath = destinations?.domains;
76
+ const domainLink = domainsPath && /^\/[a-z0-9/_-]+$/i.test(domainsPath) && canReadWebApps(me.data)
77
+ ? <Link className="btn" to={domainsPath} onClick={onClose}>Open Domains to configure this workspace</Link> : null;
78
+ const verifyAddress = () => w.run(async () => {
79
+ const value = await precheckAddress(s.owner, resolvedIntent,
80
+ address || `${slug}.${options.data?.apps_domain?.name ?? ''}`, s.signal);
81
+ setChecked(value);
82
+ if (receipt && value.verdict === 'ready' && value.domain) {
83
+ const next = saveReceipt({ ...receipt, address: { domain: value.domain.id, label: value.label } });
84
+ setReceipt(next);
85
+ }
86
+ });
87
+ const create = () => w.run(async () => {
88
+ if (!bootstrap || !eligible || !setupReady || !validSlug || (!newIntent && !addressReady)) return;
89
+ if (repository && !/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(repository)) throw new Error('Use a GitHub OWNER/REPOSITORY identifier');
90
+ if (!/^[a-z0-9_./-]{1,255}$/i.test(ref) || ref.includes('..')) throw new Error('Use a valid branch or tag');
91
+ if (!/^[a-z0-9_./-]{1,255}$/i.test(output) || output.startsWith('/') || output.split('/').some(p => !p || p === '..' || p === '.'))
92
+ throw new Error('Use a relative build output directory');
93
+ const fresh = await getBootstrap(s.owner, s.signal);
94
+ if (!canOnboard(fresh) || (workspace !== 'new' && !fresh.groups.some(g => g.id === Number(workspace))) ||
95
+ (workspace === 'new' && !fresh.can_create_webapp_group)) throw new Error('Workspace eligibility changed');
96
+ const action = makeReceipt(s.owner, 'create', crypto.randomUUID());
97
+ const profile = { operation_id: action.operation, ...intent, slug, display_name: name, environment,
98
+ bucket: options.data!.buckets[0]!, github_repository: repository, deployment_ref: ref, build_output: output };
99
+ const frozen = saveReceipt({ ...action, profile,
100
+ ...(!newIntent && checked?.domain ? { address: { domain: checked.domain.id, label: checked.label } } : {}) });
101
+ setReceipt(frozen);
102
+ acceptOperation(await createApp(s.owner, frozen, profile, s.signal), frozen);
103
+ observer.refresh();
104
+ });
105
+ const retryCreate = () => w.run(async () => {
106
+ if (!receipt?.profile) return;
107
+ try { acceptOperation(await getOperation(s.owner, receipt.operation, s.signal), receipt); observer.refresh(); }
108
+ catch (error) {
109
+ if (!(error && typeof error === 'object' && 'status' in error && error.status === 404))
110
+ throw new Error('The operation could not be observed. Refresh before retrying create.');
111
+ acceptOperation(await createApp(s.owner, receipt, receipt.profile, s.signal), receipt); observer.refresh();
112
+ }
113
+ });
114
+ const choose = () => w.run(async () => {
115
+ if (!latest || !receipt) return;
116
+ const fresh = await getOperation(s.owner, latest.operation_id, s.signal);
117
+ acceptOperation(fresh, receipt);
118
+ if (onboardingGeneration(fresh) !== generation) throw new Error('Setup changed. Review the observed cursor and choose again.');
119
+ const selected = addressReady && checked?.domain ? { domain: checked.domain.id, label: checked.label } : !address.trim() ? receipt.address : undefined;
120
+ let choice = managedChoice(fresh, selected?.domain ?? 0, selected?.label ?? slug);
121
+ if (fresh.cursor === 'github' && githubMode !== 'skip') {
122
+ if (!repository.trim()) throw new Error('Enter an OWNER/REPOSITORY before requesting verification');
123
+ choice = { repository: repository.trim(), ref, output, ...(githubMode === 'attest' ? { attest_unavailable: true } : {}) };
124
+ }
125
+ if (!choice) throw new Error('Configure a domain for the operation workspace, refresh options, then check the public address.');
126
+ if (choiceSent.current.has(generation)) throw new Error('That observed choice was already submitted. Refresh the server operation.');
127
+ choiceSent.current.add(generation);
128
+ const action = makeReceipt(s.owner, 'create', `${generation}:choice`);
129
+ try { acceptOperation(await chooseOnboarding(s.owner, action, fresh, choice, s.signal), receipt); settleReceipt(action); }
130
+ finally { observer.refresh(); }
131
+ });
132
+ const cancel = () => w.run(async () => {
133
+ if (!latest || !receipt) return;
134
+ const confirmed = await modal.open<boolean>(close => <div className="modal-pad"><h2 className="modal-title">Cancel server setup?</h2><p className="modal-message">Stop setup for {latest.profile.display_name || latest.profile.slug}. Resources already created may remain.</p><div className="modal-actions"><button className="btn" onClick={() => close(false)}>Keep setup</button><button className="btn btn-danger" onClick={() => close(true)}>Cancel setup</button></div></div>, { signal: s.signal, size: 'md' });
135
+ if (!confirmed) return;
136
+ const action = makeReceipt(s.owner, 'create', `${latest.operation_id}:cancel`);
137
+ acceptOperation(await cancelOnboarding(s.owner, action, latest.operation_id, s.signal), receipt);
138
+ settleReceipt(action); observer.refresh();
139
+ });
140
+ if (!isInteractive() || (!receipt && !canReadBootstrap(me.data)))
141
+ return <div className="modal-pad"><p>Interactive setup authority unavailable.</p><button className="btn" onClick={onClose}>Close</button></div>;
142
+ const addressFields = <><label>Public address<input value={address} disabled={w.busy} onChange={e => { setAddress(e.target.value); setChecked(null); }}
143
+ placeholder={options.data?.apps_domain ? `${slug || 'my-app'}.${options.data.apps_domain.name}` : 'An address under this workspace’s configured domain'} /></label>
144
+ <button className="btn" disabled={w.busy || !setupReady || (!address && !options.data?.apps_domain) || !validSlug} onClick={() => void verifyAddress()}>Check public address</button>
145
+ {checked && <p role="status">{checked.verdict}: {checked.hostname} {checked.reason}</p>}</>;
146
+ const setupDetails = () => { void modal.open(close => <div className="modal-pad admin-deployments"><h2 className="modal-title">{receipt ? 'Setup details' : 'Deployment destination'}</h2>{receipt ? <><Facts rows={[
147
+ ['Operation', receipt.operation], ['Workspace', latest ? `${latest.group.name} (#${latest.group.id})` : 'Not observed'], ['Revision', latest?.revision], ['Server step', latest?.cursor], ['Observation', observer.state], ['Last observed', observer.observedAt ? new Date(observer.observedAt).toLocaleTimeString() : 'Not observed'],
148
+ ]}/>{latest?.activity.length ? <><h3>Activity</h3>{latest.activity.map((a, i) => <p key={i}>{a.message}</p>)}</> : <p>No activity observed.</p>}</> : <Facts rows={[
149
+ ['Environment', environment], ['Release bucket', options.data?.buckets[0]], ['Managed domain', options.data?.apps_domain?.name],
150
+ ]}/>}<div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>, { signal: s.signal, size: 'md' }); };
151
+ const steps = [{ key: 'app', label: 'App' }, { key: 'address', label: 'Address' }, { key: 'github', label: 'Repository' }, { key: 'verify', label: 'HTTPS' }];
152
+ const currentStep = steps.findIndex(step => step.key === latest?.cursor);
153
+ return <div className="modal-pad admin-deployments">
154
+ <h2 className="modal-title">{receipt ? latest?.profile.display_name || 'WebApp setup' : 'New WebApp'}</h2>
155
+ <p className="modal-message">You can close and resume setup at any time.</p>
156
+ {!receipt ? <>
157
+ <Tabs ariaLabel="New WebApp" activeKey={createTab} onActiveKeyChange={setCreateTab} items={[
158
+ { key: 'app', label: 'App', panel: <><div className="deployment-form">
159
+ <label>Workspace<select value={workspace} disabled={w.busy} onChange={e => { setWorkspace(e.target.value); setChecked(null); }}>
160
+ {bootstrap?.groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}{bootstrap?.can_create_webapp_group && <option value="new">New workspace</option>}
161
+ </select></label>
162
+ <label>Name<input value={name} maxLength={120} disabled={w.busy} onChange={e => setName(e.target.value)} /></label>
163
+ <label>App label<input value={slug} maxLength={64} disabled={w.busy} onChange={e => { setSlug(e.target.value); setChecked(null); }} placeholder="my-app" /></label>
164
+ {!newIntent && addressFields}
165
+ </div>{newIntent && <p className="dim">Create the app first, then connect a domain to its new workspace and resume address setup.</p>}</> },
166
+ { key: 'build', label: 'Build options', panel: <div className="deployment-form">
167
+ <label>GitHub OWNER/REPOSITORY (optional)<input value={repository} disabled={w.busy} onChange={e => setRepository(e.target.value)} /></label>
168
+ <label>Branch<input value={ref} disabled={w.busy} onChange={e => setRef(e.target.value)} /></label>
169
+ <label>Build output<input value={output} disabled={w.busy} onChange={e => setOutput(e.target.value)} /></label>
170
+ <label>Environment<select value={environment} disabled={w.busy} onChange={e => setEnvironment(e.target.value)}>{(options.data?.environments ?? ['production']).map(e => <option key={e}>{e}</option>)}</select></label>
171
+ </div> },
172
+ ]}/>
173
+ {options.isPending ? <p>Resolving deployment destination…</p> : options.isError ? <ErrorNotice>Setup options unavailable.</ErrorNotice> :
174
+ !setupReady ? <ErrorNotice>{options.data?.destination_error || 'No allowed release bucket. Complete installation setup before creating an app.'}</ErrorNotice> :
175
+ <button className="btn" onClick={setupDetails}><i className="bi bi-info-circle" aria-hidden="true"/> Deployment destination</button>}
176
+ </> : <>
177
+ <ol className="deployment-setup-progress" aria-label="Setup progress">{steps.map((step, index) => <li key={step.key} aria-current={index === currentStep && !!latest && !onboardingTerminal(latest) ? 'step' : undefined} className={latest?.status === 'succeeded' || index < currentStep ? 'is-complete' : index === currentStep ? 'is-current' : ''}>{step.label}</li>)}</ol>
178
+ <div className="deployment-toolbar"><h3>{latest && onboardingTerminal(latest) ? latest.status === 'succeeded' ? 'Setup complete' : 'Setup stopped' : steps[currentStep]?.label || 'Observe saved setup'}</h3><Status value={latest?.status ?? 'unknown'}/></div>
179
+ {latest?.last_error && <ErrorNotice>{latest.last_error}</ErrorNotice>}
180
+ {(observer.state === 'error' || observer.state === 'stopped' && (!latest || onboardingPollable(latest))) && <p role="status" className="text-warn">Observation unavailable or stopped; outcome is unconfirmed. Refresh to check server setup.</p>}
181
+ {latest?.cursor === 'address' && !onboardingTerminal(latest) && <section className="deployment-stack">
182
+ <p>Public address for {latest.group.name}.</p>
183
+ {savedAddress && !address.trim() && <p role="status">A checked address is saved{savedHostname && <>: <code>{savedHostname}</code></>}. Continue to use it, or check a replacement.</p>}
184
+ {savedAddress && !!address.trim() && !addressReady && <p role="status" className="dim">Check the replacement address before continuing.</p>}
185
+ {!options.data?.apps_domain && <p role="status">{options.data?.apps_domain_error || 'No managed apps domain was observed.'} Configure or connect a domain for {latest.group.name}, then refresh and check the public address.</p>}
186
+ {domainLink}
187
+ <div><button className="btn" disabled={w.busy || options.isFetching} onClick={() => void options.refetch()}>Refresh workspace address options</button></div>
188
+ {addressFields}
189
+ </section>}
190
+ {latest?.cursor === 'github' && !onboardingTerminal(latest) && <section className="deployment-stack">
191
+ {latest.evidence.github.status && <p role="status">Repository verification: {latest.evidence.github.status}{latest.evidence.github.reason && ` · ${latest.evidence.github.reason}`}</p>}
192
+ <label>GitHub verification<select value={githubMode} disabled={w.busy} onChange={e => setGithubMode(e.target.value as 'skip' | 'verify' | 'attest')}>
193
+ <option value="skip">Skip optional repository verification</option><option value="verify">Verify the submitted repository</option>
194
+ {latest.evidence.github.status === 'unavailable' && <option value="attest">I attest this repository when verification is unavailable</option>}
195
+ </select>{githubMode !== 'skip' && <input aria-label="Repository to verify" value={repository} disabled={w.busy} onChange={e => setRepository(e.target.value)} />}</label>
196
+ {latest.evidence.github.status === 'unavailable' && <p className="dim">Unavailable verification does not attest repository ownership.</p>}
197
+ </section>}
198
+ {latest?.cursor === 'app' && !onboardingTerminal(latest) && <p>Continue to create the app in {latest.group.name}.</p>}
199
+ {latest?.cursor === 'verify' && !onboardingTerminal(latest) && <p>Continue to check HTTPS for the configured public address.</p>}
200
+ {latest?.status === 'waiting' && !latest.next_attempt_at && <p className="dim">Waiting for your choice. Automatic observation has paused.</p>}
201
+ {!latest && <p>Delivery may be uncertain. Read the saved operation before explicitly retrying its identical create receipt.</p>}
202
+ {latest && currentStep < 0 && !onboardingTerminal(latest) && <p className="text-warn">This setup step is not supported here. Refresh or inspect Setup details before continuing.</p>}
203
+ <button className="btn" onClick={setupDetails}><i className="bi bi-info-circle" aria-hidden="true"/> Setup details</button>
204
+ </>}
205
+ {!receipt && reviewApp && <p role="status" className="dim">{!name.trim() || !validSlug ? 'Add a name and valid app label on App before creating this WebApp.' : !newIntent && !addressReady ? 'Check the public address on App before creating this WebApp.' : 'Review the app and its deployment destination before creating this WebApp.'}</p>}
206
+ {w.error && <ErrorNotice>{w.error}</ErrorNotice>}
207
+ <div className="modal-actions"><button className="btn" disabled={w.busy} onClick={onClose}>Close</button>
208
+ {receipt ? <><button className="btn" disabled={w.busy} onClick={observer.refresh}>Refresh / Resume observation</button>
209
+ {!latest && receipt.profile && <button className="btn" disabled={w.busy} onClick={() => void retryCreate()}>Reconcile / retry same create</button>}
210
+ {latest && !onboardingTerminal(latest) && <><button className="btn" disabled={w.busy} onClick={() => void cancel()}>Cancel server setup</button>
211
+ <button className="btn btn-primary" disabled={w.busy || choiceSent.current.has(generation) || !['app', 'address', 'github', 'verify'].includes(latest.cursor) || (latest.cursor === 'address' && !addressChoiceReady)} onClick={() => void choose()}>
212
+ {latest.cursor === 'github' ? (githubMode === 'skip' ? 'Skip GitHub verification' : githubMode === 'attest' ? 'Attest unavailable verification' : 'Verify repository') : latest.cursor === 'address' ? 'Apply address' : latest.cursor === 'verify' ? 'Check HTTPS' : 'Create app'}
213
+ </button></>}
214
+ </> : reviewApp ? <button className="btn btn-primary" disabled={w.busy} onClick={() => setCreateTab('app')}>Review app</button> : <button className="btn btn-primary" disabled={w.busy || !creationReady} onClick={() => void create()}>Create WebApp</button>}
215
+ </div>
216
+ </div>;
217
+ }