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,208 @@
1
+ /** Installation + actor receipt registry deliberately outlives every page/dialog. */
2
+ import { apiOrigin } from '../../client/client';
3
+ import { getAuthSnapshot } from '../../client/runtime';
4
+ import {
5
+ applyMaintenance,
6
+ updateFramework,
7
+ readMaintenanceStatus,
8
+ readFrameworkDeployment, saveFrameworkPolicy, policyMatches, normalizePolicy,
9
+ MaintenanceError,
10
+ type ApplyInput,
11
+ type ResourceStatus,
12
+ type Scope,
13
+ type Framework, type FrameworkObservation,
14
+ } from './data';
15
+ export type AttemptState =
16
+ | 'sending'
17
+ | 'waiting'
18
+ | 'uncertain'
19
+ | 'refused'
20
+ | 'complete'
21
+ | 'accepted'
22
+ | 'timeout';
23
+ export interface MaintenanceAttempt {
24
+ key: string;
25
+ actor: string;
26
+ input: Readonly<ApplyInput> | { version: string } | { policy: string };
27
+ state: AttemptState;
28
+ message: string;
29
+ observations: number;
30
+ observation?: ResourceStatus;
31
+ deployment?: { id: string; sha: string; status: string };
32
+ generation: number;
33
+ observedAt?: number;
34
+ frameworkObservation?: FrameworkObservation;
35
+ }
36
+ const attempts = new Map<string, MaintenanceAttempt>();
37
+ const listeners = new Set<() => void>();
38
+ let revision = 0;
39
+ const changed = () => {
40
+ revision++;
41
+ for (const cb of listeners) cb();
42
+ };
43
+ export const subscribeMaintenanceAttempts = (cb: () => void) => {
44
+ listeners.add(cb);
45
+ return () => {
46
+ listeners.delete(cb);
47
+ };
48
+ };
49
+ export const maintenanceAttemptsRevision = () => revision;
50
+ export const maintenanceActor = () => `${apiOrigin()}|${getAuthSnapshot().uid ?? 'anonymous'}`;
51
+ export const resourceKey = (input: Pick<ApplyInput, 'kind' | 'resource'>) =>
52
+ `${input.kind}:${input.resource}`;
53
+ const scopedKey = (key: string, actor = maintenanceActor()) => `${actor}|${key}`;
54
+ export const getMaintenanceAttempts = () =>
55
+ [...attempts.values()].filter((a) => a.actor === maintenanceActor());
56
+ export const getMaintenanceAttempt = (key: string) => attempts.get(scopedKey(key));
57
+ export const maintenanceCompleted = (r: ResourceStatus) =>
58
+ r.found === true && r.settled === true && r.upgraded === true;
59
+ function claim(key: string, input: MaintenanceAttempt['input']): MaintenanceAttempt {
60
+ const id = scopedKey(key);
61
+ if (!canRequestMaintenance(key, input)) throw new MaintenanceError('attempt_requires_reconciliation');
62
+ const attempt: MaintenanceAttempt = {
63
+ key,
64
+ actor: maintenanceActor(),
65
+ input: Object.freeze({ ...input }),
66
+ state: 'sending',
67
+ message: 'Requesting operation…',
68
+ observations: 0,
69
+ generation: 0,
70
+ };
71
+ attempts.set(id, attempt);
72
+ changed();
73
+ return attempt;
74
+ }
75
+ export function canRequestMaintenance(key: string, input?: MaintenanceAttempt['input']) {
76
+ const a = getMaintenanceAttempt(key);
77
+ if ((key === 'framework' || key === 'framework-policy') && getMaintenanceAttempts().some(x => (x.key === 'framework' || x.key === 'framework-policy') && x.key !== key && !['complete','refused'].includes(x.state))) return false;
78
+ if (!a) return true;
79
+ if (a.state !== 'complete') return false;
80
+ if (!input) return true;
81
+ return 'kind' in a.input && 'kind' in input ? a.input.target_version !== input.target_version : 'version' in a.input && 'version' in input ? a.input.version !== input.version : 'policy' in a.input && 'policy' in input && a.input.policy !== input.policy;
82
+ }
83
+ function failed(a: MaintenanceAttempt, e: unknown) {
84
+ a.state = e instanceof MaintenanceError && e.mutationState === 'none' ? 'refused' : 'uncertain';
85
+ a.message =
86
+ e instanceof MaintenanceError
87
+ ? e.message
88
+ : 'The response is unknown. Reconcile provider operations before another request.';
89
+ changed();
90
+ }
91
+ export async function requestMaintenance(input: ApplyInput, scope?: Scope) {
92
+ const a = claim(resourceKey(input), input);
93
+ try {
94
+ await applyMaintenance(input, scope);
95
+ a.state = 'waiting';
96
+ a.message = 'Requested — waiting for AWS evidence.';
97
+ changed();
98
+ return a;
99
+ } catch (e) {
100
+ failed(a, e);
101
+ throw e;
102
+ }
103
+ }
104
+ export async function requestFramework(version: string, scope?: Scope) {
105
+ const a = claim('framework', { version });
106
+ try {
107
+ const result = await updateFramework(version, scope);
108
+ a.state = 'accepted';
109
+ a.deployment = result.deployment;
110
+ a.message =
111
+ 'Deployment requested. Follow its node evidence in Deployments; fleet completion is unconfirmed.';
112
+ changed();
113
+ return a;
114
+ } catch (e) {
115
+ failed(a, e);
116
+ if (a.state === 'uncertain')
117
+ a.message =
118
+ 'The pin may already be cleared while deployment acceptance is unknown. Refresh Framework and reconcile Deployments before any further update.';
119
+ changed();
120
+ throw e;
121
+ }
122
+ }
123
+ export async function requestPolicy(policy: string, scope?: Scope) {
124
+ policy = normalizePolicy(policy);
125
+ const a = claim('framework-policy', { policy });
126
+ try { await saveFrameworkPolicy(policy, scope); a.state = 'waiting'; a.message = 'Policy saved. Reading back current policy; no deployment requested.'; changed(); return a; }
127
+ catch (error) { failed(a, error); throw error; }
128
+ }
129
+ export function reconcilePolicy(f: Framework) {
130
+ const a = getMaintenanceAttempt('framework-policy');
131
+ if (!a || !('policy' in a.input) || a.state === 'sending' || a.state === 'refused' || a.state === 'complete') return;
132
+ a.observedAt = Date.now();
133
+ if (policyMatches(f.pin, a.input.policy)) {
134
+ if (a.state === 'uncertain' || a.state === 'timeout') a.message = 'Requested policy is now observed, but the original save outcome remains unknown. Readback cannot prove that request finished. Reconcile the server audit before any further policy write.';
135
+ else { a.state = 'complete'; a.message = 'Saved policy is now observed. No deployment was requested; node activation is separate.'; }
136
+ }
137
+ else a.message = 'Current policy differs from the submitted choice. Its outcome is unresolved; refresh and reconcile before another write.';
138
+ changed();
139
+ }
140
+ export async function observeFramework(a: MaintenanceAttempt, scope: Scope = {}) {
141
+ if (!('version' in a.input) || !a.deployment || ['sending','refused','complete'].includes(a.state)) return;
142
+ const generation = ++a.generation, actor = maintenanceActor();
143
+ try {
144
+ const result = await readFrameworkDeployment(a.deployment, a.input.version, scope); scope.beforeSend?.();
145
+ if (scope.signal?.aborted || actor !== maintenanceActor() || a.actor !== actor || generation !== a.generation) return;
146
+ a.frameworkObservation = result; a.observedAt = Date.now(); a.observations++;
147
+ a.state = result.terminal ? 'complete' : 'accepted';
148
+ a.message = `Deployment ${result.status}. ${result.versionProven ? 'Requested framework version observed on every deployment target.' : 'Requested framework version remains unverified.'}`;
149
+ changed();
150
+ } catch (error) {
151
+ if (scope.signal?.aborted || actor !== maintenanceActor() || generation !== a.generation) return;
152
+ a.observations++; a.message = error instanceof MaintenanceError ? error.message : 'Deployment evidence unavailable; the original update remains guarded.'; changed();
153
+ }
154
+ }
155
+ /** Only a proven pre-provider refusal is releasable by explicit successful refresh. */
156
+ export function refreshRefusedMaintenanceAttempt(key: string) {
157
+ const a = getMaintenanceAttempt(key);
158
+ if (a?.state === 'refused') {
159
+ attempts.delete(scopedKey(key));
160
+ changed();
161
+ }
162
+ }
163
+ export async function observeMaintenance(a: MaintenanceAttempt, scope: Scope = {}) {
164
+ if (!('kind' in a.input) || a.state === 'complete' || a.state === 'sending' || a.state === 'refused')
165
+ return;
166
+ const generation = ++a.generation;
167
+ const actor = maintenanceActor();
168
+ try {
169
+ const result = await readMaintenanceStatus(a.input, scope);
170
+ scope.beforeSend?.();
171
+ if (
172
+ scope.signal?.aborted ||
173
+ actor !== maintenanceActor() ||
174
+ a.actor !== actor ||
175
+ generation !== a.generation
176
+ )
177
+ return;
178
+ a.observation = result;
179
+ a.observedAt = Date.now();
180
+ a.observations++;
181
+ a.state = maintenanceCompleted(result)
182
+ ? 'complete'
183
+ : a.state === 'uncertain'
184
+ ? 'uncertain'
185
+ : 'waiting';
186
+ a.message =
187
+ a.state === 'complete'
188
+ ? `Upgraded to ${a.input.target_version}; AWS reports ${a.input.kind === 'rds-cluster' ? 'the cluster' : 'every member'} settled.`
189
+ : `${result.status ?? 'Status unknown'} — waiting/unconfirmed. A version or available state alone does not prove completion.`;
190
+ changed();
191
+ } catch (e) {
192
+ if (scope.signal?.aborted || actor !== maintenanceActor() || generation !== a.generation) return;
193
+ a.observations++;
194
+ a.message =
195
+ e instanceof MaintenanceError
196
+ ? e.message
197
+ : 'Status unavailable; the original request remains guarded.';
198
+ changed();
199
+ }
200
+ }
201
+ export function maintenanceObservationLimit(a: MaintenanceAttempt) {
202
+ if (a.state === 'waiting' || a.state === 'uncertain' || a.state === 'accepted') {
203
+ a.state = 'timeout';
204
+ a.message =
205
+ 'Automatic observations reached their limit. The attempt remains guarded. Refresh status or reconcile the provider operation and maintenance window.';
206
+ changed();
207
+ }
208
+ }
@@ -3,3 +3,4 @@ export * from '../domains/infrastructure';
3
3
  export * from '../dns';
4
4
  export * from '../storage';
5
5
  export * from '../edge';
6
+ export * from '../fleet';
@@ -2,3 +2,6 @@ export * from '../domains/operations';
2
2
  export * from '../settings';
3
3
  export * from '../jobs';
4
4
  export { FleetConfigurationPage, FleetNodeStatus } from '../fleet-configuration/FleetConfigurationPage';
5
+ export * from '../geolocation';
6
+ export * from '../maintenance';
7
+ export * from '../deployments';
@@ -2,12 +2,12 @@ import type { AdminSection } from './core';
2
2
  import './dns/setup';
3
3
  import { DASHBOARD_ADMIN_SECTION, MONITORING_ADMIN_SECTION, CLOUDWATCH_ADMIN_SECTION } from './domains/observability';
4
4
  import { USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, GROUPS_ADMIN_SECTION, PERSONAL_API_KEYS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION, SIGNIN_ADMIN_SECTION } from './domains/identity';
5
- import { SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION } from './domains/operations';
5
+ import { SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION, MAINTENANCE_ADMIN_SECTION, DEPLOYMENTS_ADMIN_SECTION, GEOLOCATION_ADMIN_SECTION } from './domains/operations';
6
6
  import {
7
7
  SECURITY_OPERATIONS_ADMIN_SECTION, BOUNCER_ADMIN_SECTION,
8
8
  DEVICE_INTEL_ADMIN_SECTION, GEOIP_ADMIN_SECTION, NETWORK_SECURITY_ADMIN_SECTION,
9
9
  } from './domains/security';
10
- import { DNS_ADMIN_SECTION, EDGE_ADMIN_SECTION, STORAGE_ADMIN_SECTION } from './domains/infrastructure';
10
+ import { FLEET_ADMIN_SECTION, DNS_ADMIN_SECTION, EDGE_ADMIN_SECTION, STORAGE_ADMIN_SECTION } from './domains/infrastructure';
11
11
  import {
12
12
  SHORTLINKS_ADMIN_SECTION, EMAIL_ADMIN_SECTION, PUBLIC_MESSAGES_ADMIN_SECTION,
13
13
  PUSH_ADMIN_SECTION, PHONE_HUB_ADMIN_SECTION,
@@ -25,6 +25,10 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
25
25
  SIGNIN_ADMIN_SECTION,
26
26
  MONITORING_ADMIN_SECTION,
27
27
  CLOUDWATCH_ADMIN_SECTION,
28
+ FLEET_ADMIN_SECTION,
29
+ MAINTENANCE_ADMIN_SECTION,
30
+ DEPLOYMENTS_ADMIN_SECTION,
31
+ GEOLOCATION_ADMIN_SECTION,
28
32
  SETTINGS_ADMIN_SECTION,
29
33
  FLEET_CONFIGURATION_ADMIN_SECTION,
30
34
  SECURITY_OPERATIONS_ADMIN_SECTION,
@@ -1,14 +1,19 @@
1
1
  import { Badge, DetailView, Eyebrow, FlatRow, fmt } from '../../ui';
2
- import { useCan } from '../../client/runtime';
2
+ import { useCan, useMe } from '../../client/runtime';
3
+ import { backendGlobalCan } from '../fleet/access';
3
4
  import {
4
5
  SETTINGS_PERMISSIONS, SettingModel, settingGroupLabel, type SettingRow,
5
6
  } from './model';
6
7
  import { showSettingEditor } from './SettingEditor';
7
8
  import { SettingValue } from './SettingValue';
9
+ import { useAdminRouteContext } from '../core/route-context';
8
10
 
9
11
  export function SettingDetail({ id, onClose }: { id: number; onClose: () => void }) {
10
12
  const { data: row, isPending, error } = SettingModel.useOne(id);
11
13
  const { can } = useCan(SETTINGS_PERMISSIONS);
14
+ const route = useAdminRouteContext();
15
+ const { data: me } = useMe();
16
+ const maintenanceHref = backendGlobalCan(me, ['view_platform','manage_platform','admin']) && route?.paths.includes(`${route.mount.replace(/\/$/, '')}/maintenance`) ? `#${route.mount.replace(/\/$/, '')}/maintenance?tab=framework` : undefined;
12
17
  if (isPending) return <div className="modal-pad dim">Loading setting…</div>;
13
18
  if (!row || error) return <div className="modal-pad text-bad">{error?.message ?? 'Setting not found'}</div>;
14
19
 
@@ -40,7 +45,7 @@ export function SettingDetail({ id, onClose }: { id: number; onClose: () => void
40
45
  The stored value is write-only. Editing with a blank replacement preserves it.
41
46
  </p>
42
47
  )}
43
- {can && (
48
+ {row.key === 'EDGE_FRAMEWORK_VERSION' ? <p className="dim">Managed by Maintenance → Django-MOJO. {maintenanceHref && <a href={maintenanceHref} onClick={onClose}>Open framework policy</a>}</p> : can && (
44
49
  <div style={{ marginTop: 16 }}>
45
50
  <button className="btn btn-compact" onClick={() => void showSettingEditor(row)}>
46
51
  <i className="bi bi-pencil" /> Edit setting
@@ -73,6 +73,7 @@ function SettingEditor({ row, close }: {
73
73
  <form className="modal-pad" onSubmit={submit} noValidate>
74
74
  <h2 className="modal-title">{row ? `Edit setting — ${row.key}` : 'Create setting'}</h2>
75
75
  {error && <div className="form-alert">{error}</div>}
76
+ {key.trim() === 'EDGE_FRAMEWORK_VERSION' && <p className="form-alert">Framework update policy is managed in Maintenance → Django-MOJO.</p>}
76
77
  <div className="form-grid">
77
78
  <div className="col-12">
78
79
  <label className="field">
@@ -142,7 +143,7 @@ function SettingEditor({ row, close }: {
142
143
  </div>
143
144
  <div className="modal-actions">
144
145
  <button type="button" className="btn" disabled={busy} onClick={() => close(null)}>Cancel</button>
145
- <button type="submit" className="btn btn-primary" disabled={busy || !key.trim()}>
146
+ <button type="submit" className="btn btn-primary" disabled={busy || !key.trim() || key.trim() === 'EDGE_FRAMEWORK_VERSION'}>
146
147
  {busy ? 'Saving…' : row ? 'Save changes' : 'Create setting'}
147
148
  </button>
148
149
  </div>
@@ -1,3 +1,4 @@
1
+ import { AdminRouteContext, useAdminRouteContext } from '../core/route-context';
1
2
  import { useQueryClient } from '@tanstack/react-query';
2
3
  import {
3
4
  Badge, ModelTable, fmt, modal, type Column, type FilterDef,
@@ -44,9 +45,10 @@ const FILTERS: FilterDef[] = [
44
45
 
45
46
  export function SettingsPage() {
46
47
  const queryClient = useQueryClient();
48
+ const routeContext = useAdminRouteContext();
47
49
  const openDetail = (row: SettingRow) => {
48
50
  void SettingModel.fetchOne(queryClient, row.id).catch(() => undefined);
49
- void modal.detail((close) => <SettingDetail id={row.id} onClose={() => close(null)} />);
51
+ void modal.detail((close) => <AdminRouteContext.Provider value={routeContext}><SettingDetail id={row.id} onClose={() => close(null)} /></AdminRouteContext.Provider>);
50
52
  };
51
53
  return (
52
54
  <ModelTable<SettingRow>
@@ -77,6 +77,7 @@ export function buildSettingPayload(draft: SettingDraft, original: SettingRow |
77
77
  const changes: Record<string, unknown> = {};
78
78
  const key = draft.key.trim();
79
79
  if (!key) throw new Error('Key is required');
80
+ if (key === 'EDGE_FRAMEWORK_VERSION' || original?.key === 'EDGE_FRAMEWORK_VERSION') throw new Error('Framework update policy is managed in Maintenance → Django-MOJO, through its dedicated owner writer.');
80
81
 
81
82
  if (original == null) {
82
83
  if (draft.is_secret && draft.value === '') throw new Error('Enter a secret value');
@@ -1,5 +1,5 @@
1
1
  // System Sign-in admin (#5547): the SYSTEM login page look & feel plus the
2
- // Google / Apple / GitHub OAuth provider credentials, over one endpoint
2
+ // Google / Apple / GitHub / Microsoft OAuth provider credentials, over one endpoint
3
3
  // (GET/POST /api/account/admin/signin, global manage_settings | admin).
4
4
  // Deliberately simple: an explicit Save for look & feel that sends only the
5
5
  // changed dotted paths, one Save per provider card, and an immediate Enabled
@@ -79,12 +79,19 @@ const FIELD_GROUPS: FieldGroup[] = [
79
79
 
80
80
  const TOKEN_LABELS: Record<string, string> = {
81
81
  password: 'Password', sms: 'SMS code', passkey: 'Passkey', magic: 'Magic link',
82
- google: 'Google', apple: 'Apple', github: 'GitHub',
82
+ google: 'Google', apple: 'Apple', github: 'GitHub', microsoft: 'Microsoft',
83
83
  'branded-panel': 'Branded panel',
84
84
  };
85
85
  const tokenLabel = (token: string) => TOKEN_LABELS[token] ?? token.charAt(0).toUpperCase() + token.slice(1).replace(/-/g, ' ');
86
86
  const LAYOUT_ALIASES: Record<string, string> = { card: 'compact', fullscreen: 'branded-panel' };
87
- const PROVIDER_ICONS: Record<string, string> = { google: 'bi-google', apple: 'bi-apple', github: 'bi-github' };
87
+ const PROVIDER_ICONS: Record<string, string> = { google: 'bi-google', apple: 'bi-apple', github: 'bi-github', microsoft: 'bi-microsoft' };
88
+ // Server-supplied card notices (#8967), e.g. an app secret about to expire. Unknown tones read as info.
89
+ type NoticeTone = NonNullable<SigninProvider['notices']>[number]['tone'];
90
+ const NOTICE_STYLES: Record<NoticeTone, { className: string; icon: string }> = {
91
+ info: { className: 'signin-note', icon: 'bi-info-circle' },
92
+ warning: { className: 'signin-note is-warning', icon: 'bi-exclamation-triangle' },
93
+ danger: { className: 'signin-note is-danger', icon: 'bi-x-octagon' },
94
+ };
88
95
  const SOURCE_LABELS: Record<SigninProviderField['source'], string> = {
89
96
  admin: 'Set in Admin', deployment: 'Set in server config', none: 'Not set',
90
97
  };
@@ -326,6 +333,13 @@ function ProviderCard({ provider, onSaved }: { provider: SigninProvider; onSaved
326
333
  <i className="bi bi-exclamation-triangle" />
327
334
  <span>Shown on the sign-in page, but it will not work until these are set: {provider.missing.join(', ')}.</span>
328
335
  </div>}
336
+ {(provider.notices ?? []).map((notice, index) => {
337
+ const style = Object.hasOwn(NOTICE_STYLES, notice.tone) ? NOTICE_STYLES[notice.tone] : NOTICE_STYLES.info;
338
+ return <div key={index} className={style.className} role="status">
339
+ <i className={`bi ${style.icon}`} />
340
+ <span>{notice.text}</span>
341
+ </div>;
342
+ })}
329
343
  {error && <div className="form-alert" role="alert">{error}</div>}
330
344
  <div className="field">
331
345
  <label className="field-label" htmlFor={`signin-${provider.name}-callback`}>Callback URL</label>
@@ -30,6 +30,8 @@ export interface SigninProvider {
30
30
  console_url: string;
31
31
  help: string;
32
32
  fields: SigninProviderField[];
33
+ /** Server-supplied card notes (#8967), e.g. a secret nearing expiry. Absent from older backends; nothing renders then. */
34
+ notices?: { tone: 'warning' | 'danger' | 'info'; text: string }[];
33
35
  }
34
36
 
35
37
  export interface SigninOptions {
@@ -1,3 +1,4 @@
1
+ import { ComboBox } from '../ui/ComboBox';
1
2
  // Shared MetricsChart presentation. The owner supplies every payload and request.
2
3
  import { useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
3
4
  import { DateRangePicker } from '../ui/date/DateRangePicker';
@@ -180,17 +181,9 @@ export function MetricsChartView({
180
181
  );
181
182
  })}
182
183
  </div>
183
- <select
184
- className="input input-compact gran-select"
185
- value={effective}
186
- disabled={disabled || available.length === 0}
187
- onChange={(e) => setGranularity(e.target.value)}
188
- aria-label="Granularity"
189
- >
190
- {GRANULARITIES.filter((g) => available.includes(g.value)).map((g) => (
191
- <option key={g.value} value={g.value}>{g.label}</option>
192
- ))}
193
- </select>
184
+ <ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} className="gran-select" value={String(effective)} disabled={disabled || available.length === 0} onChange={(selectedValue) => setGranularity(String(selectedValue))} aria-label="Granularity" options={[...(GRANULARITIES.filter((g) => available.includes(g.value)).map((g) => (
185
+ ({ value: String(g.value), label: String(g.label) })
186
+ )) ?? [])]} />
194
187
  </>
195
188
  )}
196
189
 
@@ -1,3 +1,4 @@
1
+ import { ComboBox } from '../ui/ComboBox';
1
2
  // MetricsMiniWidget — the compact metrics card: icon + title + computed
2
3
  // subtitle, windowed trending chip, embedded MiniChart sparkline, and the
3
4
  // stats / view-data / refresh / settings action row. Ported from web-mojo
@@ -154,24 +155,11 @@ function SettingsDialog({ title, initial, showDateRange, onDone }: {
154
155
  <div className="mmw-settings">
155
156
  <label className="field">
156
157
  <span className="field-label">Granularity</span>
157
- <select
158
- className="input"
159
- value={values.granularity}
160
- onChange={(e) => setValues((v) => ({ ...v, granularity: e.target.value }))}
161
- >
162
- {GRANULARITY_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
163
- </select>
158
+ <ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} value={String(values.granularity)} onChange={(selectedValue) => setValues((v) => ({ ...v, granularity: String(selectedValue) }))} options={[...(GRANULARITY_OPTIONS.map((o) => ({ value: String(o.value), label: String(o.label) })) ?? [])]} />
164
159
  </label>
165
160
  <label className="field">
166
161
  <span className="field-label">Chart type</span>
167
- <select
168
- className="input"
169
- value={values.chartType}
170
- onChange={(e) => setValues((v) => ({ ...v, chartType: e.target.value === 'bar' ? 'bar' : 'line' }))}
171
- >
172
- <option value="line">Line</option>
173
- <option value="bar">Bar</option>
174
- </select>
162
+ <ComboBox allowCustom={false} maxSuggestions={Number.MAX_SAFE_INTEGER} value={String(values.chartType)} onChange={(selectedValue) => setValues((v) => ({ ...v, chartType: String(selectedValue) === 'bar' ? 'bar' : 'line' }))} options={[({ value: "line", label: "Line" }), ({ value: "bar", label: "Bar" })]} />
175
163
  </label>
176
164
  {showDateRange && (
177
165
  <div className="field">
@@ -753,10 +753,11 @@ let freshAuthPromise: Promise<boolean> | null = null;
753
753
 
754
754
  /**
755
755
  * Register the UI that answers step-up challenges (one per app — the C3
756
- * FreshAuthHost). Pass null to unregister. The handler re-authenticates via
756
+ * FreshAuthHost). Pass null to unregister. An inline handler re-authenticates via
757
757
  * the normal login flows, so the fresh tokens (and their new auth_time) are
758
758
  * stored before it resolves true; keep the session's storage by passing
759
- * `{remember: sessionIsPersistent()}` to the login call.
759
+ * `{remember: sessionIsPersistent()}` to the login call. A hosted handler
760
+ * resolves false before leaving, so returning never replays the pending action.
760
761
  */
761
762
  export function setFreshAuthHandler(handler: FreshAuthHandler | null): void {
762
763
  freshAuthHandler = handler;
@@ -813,22 +814,22 @@ let exchangePromise: Promise<AuthUser | null> | null = null;
813
814
  * server-enforced) for tokens. Single-flight; resolves null on failure
814
815
  * (the code is burned either way — nothing to retry).
815
816
  */
816
- export function exchangeAuthCode(code: string): Promise<AuthUser | null> {
817
+ export function exchangeAuthCode(code: string, opts: Pick<LoginOptions, 'remember'> = {}): Promise<AuthUser | null> {
817
818
  if (!exchangePromise) {
818
- exchangePromise = doExchange(code).finally(() => {
819
+ exchangePromise = doExchange(code, opts.remember).finally(() => {
819
820
  exchangePromise = null;
820
821
  });
821
822
  }
822
823
  return exchangePromise;
823
824
  }
824
825
 
825
- async function doExchange(code: string): Promise<AuthUser | null> {
826
+ async function doExchange(code: string, remember = true): Promise<AuthUser | null> {
826
827
  try {
827
828
  // Hosted sign-in is also a recovery path: stale credentials must not
828
829
  // block exchanging the fresh one-time login code.
829
830
  if (checkTokenStatus().action === 'logout') clearTokens({ silent: true });
830
831
  const body = await mojoCall('/api/auth/exchange', { method: 'POST', body: { code } });
831
- return await adoptGrant(body.data as TokenGrant);
832
+ return await adoptGrant(body.data as TokenGrant, remember);
832
833
  } catch (error) {
833
834
  emitAuth('refresh-failed', error);
834
835
  return null;
@@ -846,7 +847,8 @@ async function doExchange(code: string): Promise<AuthUser | null> {
846
847
  * redirectToHostedAuth() produces by sending a hash-free return URL;
847
848
  * · inside the hash query (`/#/route?auth_code=x`) — a deployment that
848
849
  * passed a full hash-router URL as the redirect.
849
- * After a successful exchange the stashed return route (if any) is restored.
850
+ * Restores the stashed return route after exchange or an authenticated
851
+ * same-origin return (which shares storage and needs no code).
850
852
  */
851
853
  export function handleAuthCodeFromURL(): Promise<AuthUser | null> {
852
854
  if (typeof window === 'undefined' || !window.location) return Promise.resolve(null);
@@ -861,17 +863,24 @@ export function handleAuthCodeFromURL(): Promise<AuthUser | null> {
861
863
  // Hash-embedded: '#/route?auth_code=x&…'
862
864
  const hash = window.location.hash || '';
863
865
  const qIndex = hash.indexOf('?');
864
- if (qIndex === -1) return Promise.resolve(null);
866
+ if (qIndex === -1) {
867
+ completeHostedReturn(takeHostedReturn());
868
+ return Promise.resolve(null);
869
+ }
865
870
  const hashParams = new URLSearchParams(hash.slice(qIndex + 1));
866
871
  code = hashParams.get(AUTH_CODE_PARAM);
867
- if (!code) return Promise.resolve(null);
872
+ if (!code) {
873
+ completeHostedReturn(takeHostedReturn());
874
+ return Promise.resolve(null);
875
+ }
868
876
  hashParams.delete(AUTH_CODE_PARAM);
869
877
  const rest = hashParams.toString();
870
878
  scrubUrl(params, hash.slice(0, qIndex) + (rest ? `?${rest}` : ''));
871
879
  }
872
880
 
873
- return exchangeAuthCode(code).then((user) => {
874
- if (user) restoreReturnRoute();
881
+ const returning = takeHostedReturn();
882
+ return exchangeAuthCode(code, { remember: returning?.persistent }).then((user) => {
883
+ if (user) completeHostedReturn(returning);
875
884
  return user;
876
885
  });
877
886
  }
@@ -891,6 +900,18 @@ function scrubUrl(params: URLSearchParams, hash: string): void {
891
900
  // <redirect>?auth_code=<code>, which handleAuthCodeFromURL() exchanges.
892
901
 
893
902
  const RETURN_ROUTE_KEY = 'mojo:auth-return-route';
903
+ interface HostedReturn {
904
+ route: string;
905
+ returnTo: string;
906
+ createdAt: number;
907
+ persistent?: boolean;
908
+ }
909
+ export interface HostedAuthOptions {
910
+ /** Force a genuine sign-in while preserving the session's storage choice. */
911
+ forceReauth?: boolean;
912
+ /** Initiating storage choice; defaults to the current session for step-up. */
913
+ remember?: boolean;
914
+ }
894
915
 
895
916
  /**
896
917
  * The hosted auth page URL for this deployment, or null under the mock
@@ -898,36 +919,67 @@ const RETURN_ROUTE_KEY = 'mojo:auth-return-route';
898
919
  * form of the current URL — the auth page appends `?auth_code=` with plain
899
920
  * string concatenation, and a hash in the redirect would swallow it.
900
921
  */
901
- export function hostedAuthUrl(returnTo?: string): string | null {
922
+ export function hostedAuthUrl(returnTo?: string, opts: HostedAuthOptions = {}): string | null {
902
923
  const origin = apiOrigin();
903
924
  if (!origin || typeof window === 'undefined') return null;
904
925
  const target = returnTo ?? window.location.origin + window.location.pathname + window.location.search;
905
- return `${origin}/auth?redirect=${encodeURIComponent(target)}`;
926
+ return `${origin}/auth?redirect=${encodeURIComponent(target)}${opts.forceReauth ? '&force_reauth=1' : ''}`;
906
927
  }
907
928
 
908
929
  /**
909
930
  * Send the user to the hosted auth page, remembering the current in-app
910
931
  * (hash) route so the post-exchange boot can put them back where they were.
911
932
  */
912
- export function redirectToHostedAuth(): boolean {
913
- const url = hostedAuthUrl();
933
+ export function redirectToHostedAuth(opts: HostedAuthOptions = {}): boolean {
934
+ const url = hostedAuthUrl(undefined, opts);
914
935
  if (!url) return false;
915
936
  try {
916
- const route = window.location.hash;
917
- if (route && route !== '#/') sessionStorage.setItem(RETURN_ROUTE_KEY, route);
918
- } catch { /* storage denied — land on the default route */ }
937
+ const returning: HostedReturn = {
938
+ route: window.location.hash,
939
+ returnTo: window.location.origin + window.location.pathname + window.location.search,
940
+ createdAt: Date.now(),
941
+ ...(opts.forceReauth ? { persistent: opts.remember ?? sessionIsPersistent() } : {}),
942
+ };
943
+ sessionStorage.setItem(RETURN_ROUTE_KEY, JSON.stringify(returning));
944
+ } catch {
945
+ // Step-up must retain its storage choice. Ordinary login can still
946
+ // recover on the default route when session storage is unavailable.
947
+ if (opts.forceReauth) return false;
948
+ }
919
949
  window.location.href = url;
920
950
  return true;
921
951
  }
922
952
 
923
- function restoreReturnRoute(): void {
953
+ function takeHostedReturn(): HostedReturn | null {
924
954
  try {
925
- const route = sessionStorage.getItem(RETURN_ROUTE_KEY);
926
- if (route) {
927
- sessionStorage.removeItem(RETURN_ROUTE_KEY);
928
- window.location.hash = route;
929
- }
930
- } catch { /* storage denied */ }
955
+ const raw = sessionStorage.getItem(RETURN_ROUTE_KEY);
956
+ sessionStorage.removeItem(RETURN_ROUTE_KEY);
957
+ if (!raw) return null;
958
+ // Accept a route left by an older toolkit before this deployment.
959
+ if (raw.startsWith('#/')) return { route: raw, returnTo: window.location.origin + window.location.pathname + window.location.search, createdAt: Date.now() };
960
+ const value = JSON.parse(raw) as HostedReturn;
961
+ const target = new URL(value.returnTo);
962
+ if (target.origin !== window.location.origin || target.pathname !== window.location.pathname
963
+ || target.searchParams.toString() !== new URLSearchParams(window.location.search).toString()
964
+ || typeof value.route !== 'string' || value.route !== '' && !value.route.startsWith('#/')
965
+ || typeof value.createdAt !== 'number' || Date.now() < value.createdAt || Date.now() - value.createdAt > 30 * 60_000
966
+ || value.persistent !== undefined && typeof value.persistent !== 'boolean') return null;
967
+ return value;
968
+ } catch { return null; }
969
+ }
970
+
971
+ function completeHostedReturn(returning: HostedReturn | null): void {
972
+ if (!returning || !getAuthSnapshot().authenticated) return;
973
+ // Same-origin hosted login already stored the new pair in localStorage;
974
+ // code exchange adopts into the requested storage above. Return metadata
975
+ // and route restoration are never proof of fresh authentication.
976
+ const access = getAccessToken();
977
+ const refresh = getRefreshToken();
978
+ if (returning.persistent !== undefined && access && refresh) {
979
+ storeTokens(access, refresh, returning.persistent, 'refresh');
980
+ }
981
+ const target = new URL(returning.returnTo);
982
+ window.history.replaceState(window.history.state, '', target.pathname + target.search + returning.route);
931
983
  }
932
984
 
933
985
  // ── Logout + boot wiring ──────────────────────────────────────────────
@@ -85,6 +85,8 @@ export function buildQuery(params: Params): string {
85
85
  }
86
86
 
87
87
  export interface FetchOpts {
88
+ /** Domain receipt identity; the only caller-supplied request header. */
89
+ idempotencyKey?: string;
88
90
  params?: Params;
89
91
  method?: 'GET' | 'POST' | 'DELETE';
90
92
  body?: Record<string, unknown>;
@@ -145,6 +147,9 @@ export function installAuthHooks(hooks: AuthHooks | null): void {
145
147
  }
146
148
 
147
149
  async function transport(path: string, opts: FetchOpts): Promise<Envelope> {
150
+ if (opts.idempotencyKey !== undefined && (typeof opts.idempotencyKey !== 'string' || !/^[A-Za-z0-9._:-]{1,128}$/.test(opts.idempotencyKey))) {
151
+ throw new TypeError('idempotencyKey must contain 1–128 safe receipt characters');
152
+ }
148
153
  // Gate BEFORE headers are built: after a mid-gate token refresh the fresh
149
154
  // Authorization value is picked up below by construction (web-mojo had to
150
155
  // re-stamp a snapshotted header here).
@@ -157,6 +162,7 @@ async function transport(path: string, opts: FetchOpts): Promise<Envelope> {
157
162
  };
158
163
  const bearer = authHooks?.authHeader();
159
164
  if (bearer) headers['Authorization'] = bearer;
165
+ if (opts.idempotencyKey !== undefined) headers['Idempotency-Key'] = opts.idempotencyKey;
160
166
 
161
167
  if (usingMockTransport()) {
162
168
  const { mockFetch } = await loadMockModule();