portal-mojo 0.2.20 → 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 (131) hide show
  1. package/docs/README.md +7 -1
  2. package/docs/admin-deployments.md +201 -0
  3. package/docs/admin-dns-certificates.md +3 -3
  4. package/docs/admin-dns-domains.md +92 -0
  5. package/docs/admin-dns.md +4 -2
  6. package/docs/admin-edge.md +200 -196
  7. package/docs/admin-geolocation.md +100 -0
  8. package/docs/admin-maintenance.md +133 -0
  9. package/docs/admin-modals.md +17 -2
  10. package/docs/admin-operational-fleet.md +206 -0
  11. package/docs/admin-presentation.md +51 -0
  12. package/docs/admin-signin.md +12 -2
  13. package/docs/client.md +7 -0
  14. package/docs/combobox.md +18 -0
  15. package/docs/popover.md +5 -4
  16. package/package.json +1 -1
  17. package/src/admin/core/OperationsHelp.tsx +10 -0
  18. package/src/admin/core/index.ts +6 -4
  19. package/src/admin/core/route-context.ts +6 -0
  20. package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
  21. package/src/admin/deployments/DeploymentsPage.tsx +73 -0
  22. package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
  23. package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
  24. package/src/admin/deployments/WebAppDetail.tsx +104 -0
  25. package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
  26. package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
  27. package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
  28. package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
  29. package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
  30. package/src/admin/deployments/api.ts +158 -0
  31. package/src/admin/deployments/authority-state.ts +9 -0
  32. package/src/admin/deployments/deployment-state.ts +13 -0
  33. package/src/admin/deployments/index.ts +4 -0
  34. package/src/admin/deployments/onboarding-state.ts +11 -0
  35. package/src/admin/deployments/permissions.ts +37 -0
  36. package/src/admin/deployments/receipts.ts +78 -0
  37. package/src/admin/deployments/sanitize.ts +73 -0
  38. package/src/admin/deployments/shared.tsx +39 -0
  39. package/src/admin/deployments/types.ts +238 -0
  40. package/src/admin/deployments/upload.ts +53 -0
  41. package/src/admin/deployments/useDeploymentObservation.ts +104 -0
  42. package/src/admin/dns/DomainDetail.tsx +10 -6
  43. package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
  44. package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
  45. package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
  46. package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
  47. package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
  48. package/src/admin/dns/DomainsPage.tsx +22 -13
  49. package/src/admin/dns/SharedValidationDialog.tsx +1 -1
  50. package/src/admin/dns/api.ts +4 -2
  51. package/src/admin/dns/domain-navigation.ts +16 -0
  52. package/src/admin/dns/registrar-api.ts +13 -0
  53. package/src/admin/domains/assistant.ts +5 -4
  54. package/src/admin/domains/communications.ts +10 -9
  55. package/src/admin/domains/identity.ts +8 -7
  56. package/src/admin/domains/infrastructure.ts +19 -13
  57. package/src/admin/domains/lazy-page.ts +6 -0
  58. package/src/admin/domains/observability.ts +6 -5
  59. package/src/admin/domains/operations.ts +22 -7
  60. package/src/admin/domains/security.ts +16 -15
  61. package/src/admin/edge/EdgeHelp.tsx +10 -0
  62. package/src/admin/edge/ServingReadiness.tsx +54 -0
  63. package/src/admin/edge/VhostDetail.tsx +145 -139
  64. package/src/admin/edge/VhostWizard.tsx +204 -115
  65. package/src/admin/edge/VhostsPage.tsx +27 -28
  66. package/src/admin/edge/api.ts +24 -10
  67. package/src/admin/edge/models.ts +4 -4
  68. package/src/admin/edge/serving-data.ts +78 -0
  69. package/src/admin/fleet/CapacityEditor.tsx +81 -0
  70. package/src/admin/fleet/CapacityInspector.tsx +19 -0
  71. package/src/admin/fleet/CapacityProgress.tsx +9 -0
  72. package/src/admin/fleet/FleetHelp.tsx +7 -0
  73. package/src/admin/fleet/FleetOverview.tsx +150 -0
  74. package/src/admin/fleet/FleetPage.tsx +11 -0
  75. package/src/admin/fleet/FleetWorkspace.tsx +82 -0
  76. package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
  77. package/src/admin/fleet/access.ts +22 -0
  78. package/src/admin/fleet/api.ts +77 -0
  79. package/src/admin/fleet/data.ts +148 -0
  80. package/src/admin/fleet/index.ts +8 -0
  81. package/src/admin/fleet/operations.ts +179 -0
  82. package/src/admin/fleet/standalone-data.ts +87 -0
  83. package/src/admin/fleet/types.ts +208 -0
  84. package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
  85. package/src/admin/geolocation/data.ts +138 -0
  86. package/src/admin/geolocation/index.ts +3 -0
  87. package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
  88. package/src/admin/index.ts +8 -0
  89. package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
  90. package/src/admin/maintenance/MaintenancePage.tsx +442 -0
  91. package/src/admin/maintenance/data.ts +486 -0
  92. package/src/admin/maintenance/index.ts +3 -0
  93. package/src/admin/maintenance/operations.ts +208 -0
  94. package/src/admin/public/infrastructure.ts +1 -0
  95. package/src/admin/public/operations.ts +3 -0
  96. package/src/admin/registry.ts +6 -2
  97. package/src/admin/settings/SettingDetail.tsx +7 -2
  98. package/src/admin/settings/SettingEditor.tsx +2 -1
  99. package/src/admin/settings/SettingsPage.tsx +3 -1
  100. package/src/admin/settings/model.ts +1 -0
  101. package/src/admin/signin/SigninPage.tsx +17 -3
  102. package/src/admin/signin/api.ts +2 -0
  103. package/src/charts/MetricsChartView.tsx +4 -11
  104. package/src/charts/MetricsMiniWidget.tsx +3 -15
  105. package/src/client/client.ts +6 -0
  106. package/src/client/deployments-mock.ts +375 -0
  107. package/src/client/domain-operations-mock.ts +37 -0
  108. package/src/client/geo-provider-mock.ts +82 -0
  109. package/src/client/maintenance-mock.ts +285 -0
  110. package/src/client/mock.ts +52 -10
  111. package/src/client/operational-fleet-mock.ts +279 -0
  112. package/src/client/operations-mock.ts +85 -0
  113. package/src/client/serving-mock.ts +22 -0
  114. package/src/styles/components/admin-deployments.css +35 -0
  115. package/src/styles/components/admin-domains.css +12 -0
  116. package/src/styles/components/admin-edge.css +125 -1
  117. package/src/styles/components/admin-geolocation.css +40 -0
  118. package/src/styles/components/admin-maintenance.css +127 -0
  119. package/src/styles/components/admin-operational-fleet.css +38 -0
  120. package/src/styles/components/admin-operations.css +9 -0
  121. package/src/styles/components/admin-signin.css +2 -0
  122. package/src/styles/components/combobox.css +12 -2
  123. package/src/styles/components/fleet-page.css +9 -0
  124. package/src/styles/index.css +7 -0
  125. package/src/ui/ComboBox.tsx +39 -8
  126. package/src/ui/FilterBar.tsx +3 -5
  127. package/src/ui/ModelTable.tsx +2 -8
  128. package/src/ui/Popover.tsx +15 -7
  129. package/src/ui/date/TimePicker.tsx +4 -14
  130. package/src/ui/image-editor/ImageEditor.tsx +4 -5
  131. 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">
@@ -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();