portal-mojo 0.2.19 → 0.2.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/README.md +7 -1
- package/docs/admin-credentials.md +27 -0
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +160 -0
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +18 -0
- package/docs/admin-edge.md +200 -196
- package/docs/admin-fleet-configuration.md +98 -2
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-identity-directories.md +12 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +45 -2
- package/docs/admin-operational-fleet.md +206 -0
- package/docs/admin-presentation.md +51 -0
- package/docs/admin-signin.md +12 -2
- package/docs/auth-pages.md +18 -6
- package/docs/auth.md +16 -2
- package/docs/client.md +7 -0
- package/docs/collection-select.md +14 -0
- package/docs/combobox.md +18 -0
- package/docs/popover.md +5 -4
- package/package.json +1 -1
- package/src/admin/core/OperationsHelp.tsx +10 -0
- package/src/admin/core/index.ts +6 -4
- package/src/admin/core/route-context.ts +6 -0
- package/src/admin/credentials/AcmeConsumerKeyDialog.tsx +88 -0
- package/src/admin/credentials/acme-consumer-key.ts +34 -0
- package/src/admin/credentials/group-api-keys.tsx +5 -3
- package/src/admin/deployments/ApiDeploymentDetail.tsx +58 -0
- package/src/admin/deployments/DeploymentsPage.tsx +73 -0
- package/src/admin/deployments/WebAppDeploySetup.tsx +49 -0
- package/src/admin/deployments/WebAppDeploymentDetail.tsx +59 -0
- package/src/admin/deployments/WebAppDetail.tsx +104 -0
- package/src/admin/deployments/WebAppEditDialog.tsx +70 -0
- package/src/admin/deployments/WebAppKeyDialog.tsx +25 -0
- package/src/admin/deployments/WebAppOnboardingDialog.tsx +217 -0
- package/src/admin/deployments/WebAppRollbackDialog.tsx +82 -0
- package/src/admin/deployments/WebAppUploadPanel.tsx +132 -0
- package/src/admin/deployments/api.ts +158 -0
- package/src/admin/deployments/authority-state.ts +9 -0
- package/src/admin/deployments/deployment-state.ts +13 -0
- package/src/admin/deployments/index.ts +4 -0
- package/src/admin/deployments/onboarding-state.ts +11 -0
- package/src/admin/deployments/permissions.ts +37 -0
- package/src/admin/deployments/receipts.ts +78 -0
- package/src/admin/deployments/sanitize.ts +73 -0
- package/src/admin/deployments/shared.tsx +39 -0
- package/src/admin/deployments/types.ts +238 -0
- package/src/admin/deployments/upload.ts +53 -0
- package/src/admin/deployments/useDeploymentObservation.ts +104 -0
- package/src/admin/dns/CertificateDetail.tsx +8 -3
- package/src/admin/dns/CertificateRequestDialog.tsx +45 -7
- package/src/admin/dns/DomainDetail.tsx +14 -4
- package/src/admin/dns/DomainOnboardingContent.tsx +44 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +5 -24
- package/src/admin/dns/DomainPurchaseWizard.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +63 -0
- package/src/admin/dns/DomainRegistrarSettings.tsx +52 -0
- package/src/admin/dns/DomainsPage.tsx +26 -13
- package/src/admin/dns/SharedValidationDialog.tsx +298 -0
- package/src/admin/dns/SharedValidationPanel.tsx +76 -0
- package/src/admin/dns/api.ts +4 -2
- package/src/admin/dns/domain-navigation.ts +16 -0
- package/src/admin/dns/registrar-api.ts +13 -0
- package/src/admin/dns/shared-validation-api.ts +90 -0
- package/src/admin/dns/shared-validation-data.ts +106 -0
- package/src/admin/domains/assistant.ts +5 -4
- package/src/admin/domains/communications.ts +10 -9
- package/src/admin/domains/identity.ts +8 -7
- package/src/admin/domains/infrastructure.ts +19 -13
- package/src/admin/domains/lazy-page.ts +6 -0
- package/src/admin/domains/observability.ts +6 -5
- package/src/admin/domains/operations.ts +27 -10
- package/src/admin/domains/security.ts +16 -15
- package/src/admin/edge/EdgeHelp.tsx +10 -0
- package/src/admin/edge/ServingReadiness.tsx +54 -0
- package/src/admin/edge/VhostDetail.tsx +145 -139
- package/src/admin/edge/VhostWizard.tsx +204 -115
- package/src/admin/edge/VhostsPage.tsx +27 -28
- package/src/admin/edge/api.ts +24 -10
- package/src/admin/edge/models.ts +4 -4
- package/src/admin/edge/serving-data.ts +78 -0
- package/src/admin/fleet/CapacityEditor.tsx +81 -0
- package/src/admin/fleet/CapacityInspector.tsx +19 -0
- package/src/admin/fleet/CapacityProgress.tsx +9 -0
- package/src/admin/fleet/FleetHelp.tsx +7 -0
- package/src/admin/fleet/FleetOverview.tsx +150 -0
- package/src/admin/fleet/FleetPage.tsx +11 -0
- package/src/admin/fleet/FleetWorkspace.tsx +82 -0
- package/src/admin/fleet/ScaleFleetDialog.tsx +169 -0
- package/src/admin/fleet/access.ts +22 -0
- package/src/admin/fleet/api.ts +77 -0
- package/src/admin/fleet/data.ts +148 -0
- package/src/admin/fleet/index.ts +8 -0
- package/src/admin/fleet/operations.ts +179 -0
- package/src/admin/fleet/standalone-data.ts +87 -0
- package/src/admin/fleet/types.ts +208 -0
- package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +14 -8
- package/src/admin/fleet-configuration/SharedValidationConnectionPanel.tsx +166 -0
- package/src/admin/fleet-configuration/data.ts +4 -0
- package/src/admin/fleet-configuration/operator-destinations.ts +20 -0
- package/src/admin/fleet-configuration/shared-validation-data.ts +143 -0
- package/src/admin/geolocation/GeoLocationPage.tsx +171 -0
- package/src/admin/geolocation/data.ts +138 -0
- package/src/admin/geolocation/index.ts +3 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +73 -2
- package/src/admin/identity/groups/models.ts +2 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +2 -8
- package/src/admin/index.ts +8 -0
- package/src/admin/maintenance/MaintenanceDialogs.tsx +161 -0
- package/src/admin/maintenance/MaintenancePage.tsx +442 -0
- package/src/admin/maintenance/data.ts +486 -0
- package/src/admin/maintenance/index.ts +3 -0
- package/src/admin/maintenance/operations.ts +208 -0
- package/src/admin/public/infrastructure.ts +1 -0
- package/src/admin/public/operations.ts +3 -0
- package/src/admin/registry.ts +6 -2
- package/src/admin/settings/SettingDetail.tsx +7 -2
- package/src/admin/settings/SettingEditor.tsx +2 -1
- package/src/admin/settings/SettingsPage.tsx +3 -1
- package/src/admin/settings/model.ts +1 -0
- package/src/admin/signin/SigninPage.tsx +17 -3
- package/src/admin/signin/api.ts +2 -0
- package/src/charts/MetricsChartView.tsx +4 -11
- package/src/charts/MetricsMiniWidget.tsx +3 -15
- package/src/client/auth.ts +77 -25
- package/src/client/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/fleet-mock.ts +73 -1
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/index.ts +2 -2
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +280 -11
- package/src/client/operational-fleet-mock.ts +279 -0
- package/src/client/operations-mock.ts +85 -0
- package/src/client/serving-mock.ts +22 -0
- package/src/styles/components/admin-deployments.css +35 -0
- package/src/styles/components/admin-dns-certificates.css +33 -0
- package/src/styles/components/admin-domains.css +12 -0
- package/src/styles/components/admin-edge.css +125 -1
- package/src/styles/components/admin-geolocation.css +40 -0
- package/src/styles/components/admin-maintenance.css +127 -0
- package/src/styles/components/admin-operational-fleet.css +38 -0
- package/src/styles/components/admin-operations.css +9 -0
- package/src/styles/components/admin-signin.css +2 -0
- package/src/styles/components/combobox.css +12 -2
- package/src/styles/components/fleet-page.css +9 -0
- package/src/styles/index.css +7 -0
- package/src/ui/CollectionSelect.tsx +30 -8
- package/src/ui/ComboBox.tsx +39 -8
- package/src/ui/FilterBar.tsx +3 -5
- package/src/ui/ModelTable.tsx +2 -8
- package/src/ui/Popover.tsx +15 -7
- package/src/ui/date/TimePicker.tsx +4 -14
- package/src/ui/image-editor/ImageEditor.tsx +4 -5
- package/src/ui/schema-form-core.tsx +3 -5
|
@@ -0,0 +1,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
|
+
}
|
|
@@ -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';
|
package/src/admin/registry.ts
CHANGED
|
@@ -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>
|
package/src/admin/signin/api.ts
CHANGED
|
@@ -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
|
-
|
|
185
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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">
|
package/src/client/auth.ts
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
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)
|
|
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)
|
|
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
|
-
|
|
874
|
-
|
|
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
|
|
917
|
-
|
|
918
|
-
|
|
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
|
|
953
|
+
function takeHostedReturn(): HostedReturn | null {
|
|
924
954
|
try {
|
|
925
|
-
const
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
}
|
|
930
|
-
|
|
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 ──────────────────────────────────────────────
|
package/src/client/client.ts
CHANGED
|
@@ -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();
|