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.
- package/docs/README.md +7 -1
- package/docs/admin-deployments.md +201 -0
- package/docs/admin-dns-certificates.md +3 -3
- package/docs/admin-dns-domains.md +92 -0
- package/docs/admin-dns.md +4 -2
- package/docs/admin-edge.md +200 -196
- package/docs/admin-geolocation.md +100 -0
- package/docs/admin-maintenance.md +133 -0
- package/docs/admin-modals.md +17 -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/client.md +7 -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/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/DomainDetail.tsx +10 -6
- 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 +22 -13
- package/src/admin/dns/SharedValidationDialog.tsx +1 -1
- 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/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 +22 -7
- 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/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/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/client.ts +6 -0
- package/src/client/deployments-mock.ts +375 -0
- package/src/client/domain-operations-mock.ts +37 -0
- package/src/client/geo-provider-mock.ts +82 -0
- package/src/client/maintenance-mock.ts +285 -0
- package/src/client/mock.ts +52 -10
- 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-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/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,238 @@
|
|
|
1
|
+
/** Safe display DTOs. Raw server objects and upload/key material never reach Query. */
|
|
2
|
+
export interface Release {
|
|
3
|
+
id: number;
|
|
4
|
+
version: string;
|
|
5
|
+
status: string;
|
|
6
|
+
source: string;
|
|
7
|
+
created: string;
|
|
8
|
+
}
|
|
9
|
+
export interface WebApp {
|
|
10
|
+
id: number;
|
|
11
|
+
slug: string;
|
|
12
|
+
display_name: string;
|
|
13
|
+
environment: string;
|
|
14
|
+
deployment_ref: string;
|
|
15
|
+
repository: string;
|
|
16
|
+
build_output: string;
|
|
17
|
+
}
|
|
18
|
+
export interface WebAppObject extends WebApp {
|
|
19
|
+
group: {
|
|
20
|
+
id: number;
|
|
21
|
+
name: string;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface Certificate {
|
|
25
|
+
status: string;
|
|
26
|
+
not_after: string;
|
|
27
|
+
}
|
|
28
|
+
export interface Attempt {
|
|
29
|
+
id: number;
|
|
30
|
+
started?: string;
|
|
31
|
+
detail?: string;
|
|
32
|
+
status: string;
|
|
33
|
+
created: string;
|
|
34
|
+
finished: string;
|
|
35
|
+
release: Release | null;
|
|
36
|
+
}
|
|
37
|
+
export interface WebAppSummary {
|
|
38
|
+
webapp: WebApp;
|
|
39
|
+
address: {
|
|
40
|
+
hostname: string;
|
|
41
|
+
certificate: Certificate | null;
|
|
42
|
+
aliases: {
|
|
43
|
+
hostname: string;
|
|
44
|
+
certificate: Certificate | null;
|
|
45
|
+
}[];
|
|
46
|
+
} | null;
|
|
47
|
+
current_release: Release | null;
|
|
48
|
+
latest_deployment: Attempt | null;
|
|
49
|
+
onboarding: {
|
|
50
|
+
status: string;
|
|
51
|
+
cursor: string;
|
|
52
|
+
};
|
|
53
|
+
deployment_key: {
|
|
54
|
+
linked: boolean;
|
|
55
|
+
active: boolean;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export interface WebAppList {
|
|
59
|
+
items: WebAppSummary[];
|
|
60
|
+
count: number;
|
|
61
|
+
limit: number;
|
|
62
|
+
truncated: boolean;
|
|
63
|
+
}
|
|
64
|
+
export interface Proof {
|
|
65
|
+
runner: string;
|
|
66
|
+
status: string;
|
|
67
|
+
detail: string;
|
|
68
|
+
sha: string;
|
|
69
|
+
deployment: string;
|
|
70
|
+
stderr_tail: string;
|
|
71
|
+
}
|
|
72
|
+
export interface PlatformAttempt {
|
|
73
|
+
id: string;
|
|
74
|
+
sha: string;
|
|
75
|
+
framework_version: string;
|
|
76
|
+
status: string;
|
|
77
|
+
source: string;
|
|
78
|
+
actor: string;
|
|
79
|
+
retry_of: string;
|
|
80
|
+
created: string;
|
|
81
|
+
started: string;
|
|
82
|
+
finished: string;
|
|
83
|
+
duration_seconds: number | null;
|
|
84
|
+
desired_commit: string;
|
|
85
|
+
current_commits: string[];
|
|
86
|
+
frozen_roster: string[];
|
|
87
|
+
transitions: Proof[];
|
|
88
|
+
node_evidence: Proof[];
|
|
89
|
+
diagnosis: Proof[];
|
|
90
|
+
detail: string;
|
|
91
|
+
node_summary: Record<'expected' | 'proven' | 'reported' | 'failed' | 'dispatched' | 'other', number>;
|
|
92
|
+
}
|
|
93
|
+
export interface Collector<T> {
|
|
94
|
+
status: string;
|
|
95
|
+
observed_at: string;
|
|
96
|
+
stale_after: string;
|
|
97
|
+
reason: string;
|
|
98
|
+
data: T | null;
|
|
99
|
+
}
|
|
100
|
+
export interface PlatformSnapshot {
|
|
101
|
+
deployments: Collector<{
|
|
102
|
+
items: PlatformAttempt[];
|
|
103
|
+
limit: number;
|
|
104
|
+
desired_commit: string;
|
|
105
|
+
desired_deployment: string;
|
|
106
|
+
currently_serving: {
|
|
107
|
+
deployment: string;
|
|
108
|
+
sha: string;
|
|
109
|
+
framework_version: string;
|
|
110
|
+
converged_at: string;
|
|
111
|
+
} | null;
|
|
112
|
+
coordination: {
|
|
113
|
+
state: string;
|
|
114
|
+
deployment: string;
|
|
115
|
+
sha: string;
|
|
116
|
+
at: string;
|
|
117
|
+
};
|
|
118
|
+
framework_pin: {
|
|
119
|
+
mode: string;
|
|
120
|
+
resolved: string;
|
|
121
|
+
};
|
|
122
|
+
}>;
|
|
123
|
+
api: Collector<{
|
|
124
|
+
version: string;
|
|
125
|
+
commit: string;
|
|
126
|
+
}>;
|
|
127
|
+
}
|
|
128
|
+
export interface Bootstrap {
|
|
129
|
+
groups: {
|
|
130
|
+
id: number;
|
|
131
|
+
name: string;
|
|
132
|
+
can_manage_dns: boolean;
|
|
133
|
+
}[];
|
|
134
|
+
can_create_webapp_group: boolean;
|
|
135
|
+
setup: boolean;
|
|
136
|
+
}
|
|
137
|
+
export interface DeploymentStatus {
|
|
138
|
+
deployment: number;
|
|
139
|
+
webapp: number;
|
|
140
|
+
release: number;
|
|
141
|
+
version: string;
|
|
142
|
+
status: string;
|
|
143
|
+
terminal: boolean;
|
|
144
|
+
success: boolean;
|
|
145
|
+
detail: string;
|
|
146
|
+
created: string;
|
|
147
|
+
started: string;
|
|
148
|
+
finished: string;
|
|
149
|
+
targets: Proof[];
|
|
150
|
+
rollback_targets: Proof[];
|
|
151
|
+
}
|
|
152
|
+
export interface CreateProfile {
|
|
153
|
+
operation_id: string;
|
|
154
|
+
group?: number;
|
|
155
|
+
group_intent?: 'new';
|
|
156
|
+
slug: string;
|
|
157
|
+
display_name: string;
|
|
158
|
+
environment: string;
|
|
159
|
+
bucket: string;
|
|
160
|
+
github_repository: string;
|
|
161
|
+
deployment_ref: string;
|
|
162
|
+
build_output: string;
|
|
163
|
+
}
|
|
164
|
+
export interface OnboardingOperation {
|
|
165
|
+
operation_id: string;
|
|
166
|
+
group: {
|
|
167
|
+
id: number;
|
|
168
|
+
name: string;
|
|
169
|
+
};
|
|
170
|
+
status: string;
|
|
171
|
+
cursor: string;
|
|
172
|
+
revision: number;
|
|
173
|
+
profile: {
|
|
174
|
+
slug: string;
|
|
175
|
+
display_name: string;
|
|
176
|
+
};
|
|
177
|
+
evidence: {
|
|
178
|
+
app: {
|
|
179
|
+
status: string;
|
|
180
|
+
};
|
|
181
|
+
address: {
|
|
182
|
+
status: string;
|
|
183
|
+
hostname: string;
|
|
184
|
+
};
|
|
185
|
+
github: {
|
|
186
|
+
status: string;
|
|
187
|
+
reason: string;
|
|
188
|
+
};
|
|
189
|
+
verify: {
|
|
190
|
+
status: string;
|
|
191
|
+
detail: string;
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
activity: {
|
|
195
|
+
message: string;
|
|
196
|
+
kind: string;
|
|
197
|
+
at: string;
|
|
198
|
+
}[];
|
|
199
|
+
resources: {
|
|
200
|
+
webapp: number | null;
|
|
201
|
+
};
|
|
202
|
+
next_attempt_at: string;
|
|
203
|
+
last_error: string;
|
|
204
|
+
modified: string;
|
|
205
|
+
}
|
|
206
|
+
export interface OnboardingOptions {
|
|
207
|
+
buckets: string[];
|
|
208
|
+
environments: string[];
|
|
209
|
+
destination: boolean;
|
|
210
|
+
destination_error: string;
|
|
211
|
+
apps_domain: {
|
|
212
|
+
id: number;
|
|
213
|
+
name: string;
|
|
214
|
+
} | null;
|
|
215
|
+
apps_domain_error: string;
|
|
216
|
+
github_connected: boolean;
|
|
217
|
+
}
|
|
218
|
+
export interface AddressPrecheck {
|
|
219
|
+
verdict: string;
|
|
220
|
+
hostname: string;
|
|
221
|
+
reason: string;
|
|
222
|
+
domain: {
|
|
223
|
+
id: number;
|
|
224
|
+
name: string;
|
|
225
|
+
} | null;
|
|
226
|
+
label: string;
|
|
227
|
+
}
|
|
228
|
+
export interface ManifestEntry {
|
|
229
|
+
path: string;
|
|
230
|
+
sha256: string;
|
|
231
|
+
size: number;
|
|
232
|
+
}
|
|
233
|
+
export type ApiAction = 'retry' | 'verify' | 'converge';
|
|
234
|
+
/** Registered router paths supplied by the host, already checked against its section roster. */
|
|
235
|
+
export interface DeploymentsDestinations {
|
|
236
|
+
domains?: string;
|
|
237
|
+
serving?: string;
|
|
238
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ManifestEntry } from './types';
|
|
2
|
+
export function normalizeUploadPath(path: string): string { const p = path.normalize('NFC'); if (!p || p.length > 1024 || p.startsWith('/') || p.includes('\\') || /[\u0000-\u001f]/.test(p) || p.split('/').some(x => !x || x === '.' || x === '..') || /^[a-z]:/i.test(p))
|
|
3
|
+
throw new Error('Choose normalized relative file paths without traversal or empty segments'); return p; }
|
|
4
|
+
export async function buildManifest(files: File[], signal?: AbortSignal, limits = { files: 5000, bytes: 1024 ** 3 }): Promise<ManifestEntry[]> { if (!files.length || files.length > limits.files || files.reduce((n, f) => n + f.size, 0) > limits.bytes)
|
|
5
|
+
throw new Error(`Select 1–${limits.files} files, at most ${limits.bytes} bytes. This installation may impose lower limits.`); const seen = new Set<string>(), manifest: ManifestEntry[] = []; for (const file of files) {
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
const path = normalizeUploadPath(file.webkitRelativePath || file.name);
|
|
8
|
+
if (seen.has(path))
|
|
9
|
+
throw new Error(`Duplicate normalized file path: ${path}`);
|
|
10
|
+
seen.add(path);
|
|
11
|
+
const hash = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
manifest.push({
|
|
14
|
+
path, sha256: [...new Uint8Array(hash)].map(x => x.toString(16).padStart(2, '0')).join(''), size: file.size
|
|
15
|
+
});
|
|
16
|
+
} return manifest.sort((a, b) => a.path.localeCompare(b.path)); }
|
|
17
|
+
export function manifestEqual(a: ManifestEntry[], b: ManifestEntry[]): boolean { return JSON.stringify(a) === JSON.stringify(b); }
|
|
18
|
+
export async function putReleaseFiles(files: File[], manifest: ManifestEntry[], uploads: {
|
|
19
|
+
path: string;
|
|
20
|
+
url: string;
|
|
21
|
+
headers: Record<string, string>;
|
|
22
|
+
}[], signal: AbortSignal, onProgress: (n: number) => void, put: typeof fetch = fetch): Promise<void> { if (uploads.length === 0)
|
|
23
|
+
return; if (uploads.length !== manifest.length)
|
|
24
|
+
throw new Error('Incomplete signed upload manifest'); const source = new Map(files.map(f => [normalizeUploadPath(f.webkitRelativePath || f.name), f])), seen = new Set<string>(); let completed = 0; for (const upload of uploads) {
|
|
25
|
+
signal.throwIfAborted();
|
|
26
|
+
const entry = manifest.find(x => x.path === upload.path), file = source.get(upload.path);
|
|
27
|
+
if (!entry || !file || seen.has(upload.path))
|
|
28
|
+
throw new Error('Upload path does not match the selected manifest');
|
|
29
|
+
seen.add(upload.path);
|
|
30
|
+
let url: URL;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(upload.url);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new Error('Invalid storage upload destination');
|
|
36
|
+
}
|
|
37
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
38
|
+
throw new Error('Storage requires an HTTPS signed destination');
|
|
39
|
+
const expected = btoa(String.fromCharCode(...Uint8Array.from(entry.sha256.match(/../g) ?? [], x => parseInt(x, 16))));
|
|
40
|
+
if (Object.keys(upload.headers).length !== 1 || upload.headers['x-amz-checksum-sha256'] !== expected)
|
|
41
|
+
throw new Error('Storage checksum headers do not match this file');
|
|
42
|
+
try {
|
|
43
|
+
const response = await put(url.href, {
|
|
44
|
+
method: 'PUT', headers: upload.headers, body: file, credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', signal
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new Error('upload refused');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error('Storage upload failed. Check the bucket CORS policy permits anonymous PUT and x-amz-checksum-sha256 from this Admin origin. Completion was not sent.');
|
|
51
|
+
}
|
|
52
|
+
onProgress(++completed);
|
|
53
|
+
} }
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useLayoutEffect, useState } from 'react';
|
|
2
|
+
import { getAuthSessionRevision, useAuthSnapshot, useMe } from '../../client/runtime';
|
|
3
|
+
import { backendGlobalCan } from '../fleet/access';
|
|
4
|
+
import { assertOwner, deploymentOwner, ownerKey, type DeploymentOwner } from './permissions';
|
|
5
|
+
/** One identity/scope lifetime, including React's development-only effect replay. */
|
|
6
|
+
export function useDeploymentOwner() {
|
|
7
|
+
const auth = useAuthSnapshot(), me = useMe(), revision = getAuthSessionRevision();
|
|
8
|
+
const base = useMemo(() => deploymentOwner(), [auth.uid, auth.authenticated, revision]);
|
|
9
|
+
const authority = [me.data?.is_active !== false, ...[
|
|
10
|
+
'view_platform', 'manage_platform', 'admin', 'view_dns', 'manage_dns',
|
|
11
|
+
'security', 'manage_webapp', 'view_admin', 'manage_users', 'manage_settings',
|
|
12
|
+
].map(permission => backendGlobalCan(me.data, permission))].join(':');
|
|
13
|
+
const key = `${ownerKey(base)}:${authority}`;
|
|
14
|
+
const lifetime = useMemo(() => ({ controller: new AbortController() }), [key]);
|
|
15
|
+
const [, renderLiveSignal] = useState(0);
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
// StrictMode replays setup after cleanup without replacing query keys.
|
|
18
|
+
// Renew this same scope before its read effects resume, and publish the
|
|
19
|
+
// new signal to consumers that capture a concrete signal during render.
|
|
20
|
+
if (lifetime.controller.signal.aborted) {
|
|
21
|
+
lifetime.controller = new AbortController();
|
|
22
|
+
renderLiveSignal(generation => generation + 1);
|
|
23
|
+
}
|
|
24
|
+
const mountedController = lifetime.controller;
|
|
25
|
+
return () => mountedController.abort();
|
|
26
|
+
}, [lifetime]);
|
|
27
|
+
// Query callbacks from the first render must see the controller belonging
|
|
28
|
+
// to the current setup, rather than a permanently aborted render snapshot.
|
|
29
|
+
// A genuine unmount has no following setup; its lifetime remains aborted.
|
|
30
|
+
// A different actor/session/authority receives a separate lifetime object.
|
|
31
|
+
const owner = useMemo<DeploymentOwner>(() => ({
|
|
32
|
+
...base,
|
|
33
|
+
get signal() { return lifetime.controller.signal; },
|
|
34
|
+
}), [base, lifetime]);
|
|
35
|
+
return { owner, signal: lifetime.controller.signal, key };
|
|
36
|
+
}
|
|
37
|
+
export interface Observation<T> {
|
|
38
|
+
value: T | null;
|
|
39
|
+
state: 'idle' | 'observing' | 'stopped' | 'terminal' | 'error';
|
|
40
|
+
observedAt: number | null;
|
|
41
|
+
refresh: () => void;
|
|
42
|
+
}
|
|
43
|
+
/** Known identity only; bounded, owner-fenced, hidden/blur aware; refresh only reads. */
|
|
44
|
+
export function useDeploymentObservation<T>(owner: DeploymentOwner, identity: string | null, read: (signal: AbortSignal) => Promise<T>, terminal: (value: T) => boolean, pollable: (value: T) => boolean = () => true, budgetMs = 60000, intervalMs = 2000): Observation<T> {
|
|
45
|
+
const [generation, setGeneration] = useState(0), [value, setValue] = useState<T | null>(null), [state, setState] = useState<Observation<T>['state']>('idle'), [observedAt, setObservedAt] = useState<number | null>(null);
|
|
46
|
+
const current = useRef({
|
|
47
|
+
read, terminal, pollable
|
|
48
|
+
});
|
|
49
|
+
current.current = {
|
|
50
|
+
read, terminal, pollable
|
|
51
|
+
};
|
|
52
|
+
const key = ownerKey(owner), slot = `${key}:${identity}:${generation}`;
|
|
53
|
+
const seen = useRef('');
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
seen.current = slot;
|
|
56
|
+
if (!identity) {
|
|
57
|
+
setValue(null);
|
|
58
|
+
setState('idle');
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
let cancelled = false;
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
setValue(null);
|
|
66
|
+
setObservedAt(null);
|
|
67
|
+
setState('observing');
|
|
68
|
+
const tick = async () => { if (cancelled)
|
|
69
|
+
return; if (Date.now() - started >= budgetMs) {
|
|
70
|
+
setState('stopped');
|
|
71
|
+
return;
|
|
72
|
+
} if (typeof document !== 'undefined' && (document.visibilityState === 'hidden' || !document.hasFocus())) {
|
|
73
|
+
timer = setTimeout(tick, intervalMs);
|
|
74
|
+
return;
|
|
75
|
+
} try {
|
|
76
|
+
assertOwner(owner, controller.signal);
|
|
77
|
+
const next = await current.current.read(controller.signal);
|
|
78
|
+
assertOwner(owner, controller.signal);
|
|
79
|
+
if (cancelled)
|
|
80
|
+
return;
|
|
81
|
+
setValue(next);
|
|
82
|
+
setObservedAt(Date.now());
|
|
83
|
+
if (current.current.terminal(next)) {
|
|
84
|
+
setState('terminal');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!current.current.pollable(next)) {
|
|
88
|
+
setState('stopped');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
timer = setTimeout(tick, intervalMs);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
if (!cancelled)
|
|
95
|
+
setState('error');
|
|
96
|
+
} };
|
|
97
|
+
void tick();
|
|
98
|
+
return () => { cancelled = true; controller.abort(); clearTimeout(timer); };
|
|
99
|
+
}, [key, owner.signal, identity, generation, budgetMs, intervalMs]);
|
|
100
|
+
const matching = seen.current === slot;
|
|
101
|
+
return {
|
|
102
|
+
value: matching ? value : null, state: matching ? state : identity ? 'observing' : 'idle', observedAt: matching ? observedAt : null, refresh: useCallback(() => setGeneration(n => n + 1), [])
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -9,7 +9,9 @@ import { DomainCertificatesSection } from './DomainCertificatesSection';
|
|
|
9
9
|
import { SharedValidationPanel } from './SharedValidationPanel';
|
|
10
10
|
import { openSharedValidation } from './SharedValidationDialog';
|
|
11
11
|
import { useValidationPlan } from './shared-validation-api';
|
|
12
|
-
import {
|
|
12
|
+
import { domainRecordsHref, domainScopeLabel, dnsAdminMount, supportsDomainRecords } from './domain-navigation';
|
|
13
|
+
import { redactRegistrarError } from './purchase-data';
|
|
14
|
+
import { DNS_MANAGE_PERMISSIONS, DNS_VIEW_PERMISSIONS, DomainModel } from './models';
|
|
13
15
|
|
|
14
16
|
export function DomainDetail({ id, onClose }: { id: number; onClose: () => void }) {
|
|
15
17
|
const query = DomainModel.useOne(id);
|
|
@@ -30,18 +32,20 @@ export function DomainDetail({ id, onClose }: { id: number; onClose: () => void
|
|
|
30
32
|
onClose();
|
|
31
33
|
navigate(`${mount}/dns/${route.replace(/^\/?dns\//, '').replace(/^\//, '')}`);
|
|
32
34
|
};
|
|
33
|
-
const groupLabel =
|
|
35
|
+
const groupLabel = domainScopeLabel(domain.group);
|
|
36
|
+
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
37
|
+
const openDestination = (href: string) => { onClose(); navigate(href); };
|
|
34
38
|
const credential = typeof domain.credential === 'object' ? domain.credential : null;
|
|
35
39
|
return <DetailView
|
|
36
|
-
icon="bi-globe2" title={domain.name} subtitle={
|
|
40
|
+
icon="bi-globe2" title={domain.name} subtitle={providerLabel(domain.provider)}
|
|
37
41
|
chips={[{ text: domain.status, tone: fmt.inferTone(domain.status) }, ...(domain.verified ? [{ text: 'Verified', tone: 'success' as const }] : [])]}
|
|
38
42
|
sections={[
|
|
39
|
-
{ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="dns-domain-overview"><
|
|
40
|
-
{ key: 'validation', label: '
|
|
43
|
+
{ key: 'overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => <div className="dns-domain-overview"><div className="domains-overview-actions">{supportsDomainRecords(domain.provider) && <button className="btn btn-primary" onClick={() => openDestination(domainRecordsHref(location.pathname, domain.id))}>DNS Records <i className="bi bi-arrow-right" /></button>}{canManage && domain.provider === 'route53' && <button className="btn" onClick={() => void import('./DomainRegistrarSettings').then(({ showDomainRegistrarSettings }) => showDomainRegistrarSettings(domain))}>Registrar settings</button>}{hasPermission(me ?? null, DNS_VIEW_PERMISSIONS, null) && <button className="btn" onClick={() => openDestination(`${dnsAdminMount(location.pathname)}/edge/vhosts`)}>Serving <i className="bi bi-arrow-right" /></button>}</div><Eyebrow>Domain</Eyebrow><FlatRow label="Scope">{groupLabel}</FlatRow><FlatRow label="Expires">{fmt.date(domain.expires)}</FlatRow><FlatRow label="Auto-renew">{domain.auto_renew ? 'Enabled' : 'Disabled'}</FlatRow><FlatRow label="WHOIS privacy">{domain.privacy ? 'Enabled' : 'Disabled'}</FlatRow><details className="ops-details"><summary>Registration details</summary>{domain.hosted_zone_id && <FlatRow label="Hosted zone"><code>{domain.hosted_zone_id}</code></FlatRow>}<FlatRow label="Registered">{fmt.date(domain.registered_on)}</FlatRow><FlatRow label="Credential">{credential ? `${credential.name} · ${credential.is_active && credential.verified ? 'usable' : 'unavailable'}` : '—'}</FlatRow></details>{domain.last_error && <div className="dns-error">{redactRegistrarError(domain.last_error)}</div>}{domain.provider === 'godaddy' && <div className="dns-provider-note">DNS is managed here; purchase, renewal, privacy, and WHOIS remain in the GoDaddy account.</div>}{domain.provider === 'mojo' && <div className="dns-blocked">Mojo is certificate-only and cannot use generic DNS CRUD.</div>}{availableLinks.length > 0 && <><Eyebrow>Related</Eyebrow><div className="dns-domain-links">{availableLinks.filter((link) => link.key !== 'records' || supportsDomainRecords(domain.provider)).map((link) => <button className="btn" type="button" key={link.key} onClick={() => navigateLink(typeof link.route === 'function' ? link.route(domain) : link.route)}><i className={`bi ${link.icon}`} /> {link.label}</button>)}</div></>}</div> },
|
|
44
|
+
{ key: 'validation', label: 'Advanced validation', icon: 'bi-diagram-2', render: () => <SharedValidationPanel domain={domain} /> },
|
|
41
45
|
{ key: 'records', label: 'DNS Records', icon: 'bi-list-columns', render: () => <DnsRecordsPanel domain={domain} /> },
|
|
42
46
|
{ key: 'certificates', label: 'Certificates', icon: 'bi-patch-check', render: () => <DomainCertificatesSection domain={domain} /> },
|
|
43
47
|
]}
|
|
44
48
|
initialSection="overview" onClose={onClose}
|
|
45
|
-
contextMenu={
|
|
49
|
+
contextMenu={canManage && shared.data?.capabilities.group_choices ? [{ label: 'Set up shared validation', icon: 'bi-diagram-2', onSelect: () => void openSharedValidation(domain) }] : []}
|
|
46
50
|
/>;
|
|
47
51
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { hasPermission, useMe } from '../../client/runtime';
|
|
4
|
+
import { toast } from '../../ui';
|
|
5
|
+
import { adoptHouseDomain } from './api';
|
|
6
|
+
import { DNS_MANAGE_PERMISSIONS, DomainModel } from './models';
|
|
7
|
+
import { normalizePurchaseDomain, redactRegistrarError } from './purchase-data';
|
|
8
|
+
import { DomainPurchaseWizard } from './DomainPurchaseWizard';
|
|
9
|
+
import { DomainRegisterExisting } from './DomainRegisterExisting';
|
|
10
|
+
|
|
11
|
+
export type OnboardingMode = 'menu' | 'register' | 'purchase' | 'adopt';
|
|
12
|
+
|
|
13
|
+
export function DomainOnboardingDialog({ close, setBusyGate, initialMode = 'menu' }: { close: () => void; setBusyGate: (busy: boolean) => void; initialMode?: OnboardingMode }) {
|
|
14
|
+
const { data: me } = useMe(); const queryClient = useQueryClient();
|
|
15
|
+
const [mode, setMode] = useState<OnboardingMode>(initialMode);
|
|
16
|
+
const [name, setName] = useState(''); const [review, setReview] = useState<string | null>(null);
|
|
17
|
+
const [busy, setBusy] = useState(false); const [attempted, setAttempted] = useState(false); const [error, setError] = useState('');
|
|
18
|
+
const inFlight = useRef(false);
|
|
19
|
+
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
20
|
+
const prepareAdoption = () => {
|
|
21
|
+
if (inFlight.current || attempted || me?.is_superuser !== true) return;
|
|
22
|
+
try { setReview(normalizePurchaseDomain(name)); setError(''); setBusyGate(true); }
|
|
23
|
+
catch (reason) { setError(redactRegistrarError(reason)); }
|
|
24
|
+
};
|
|
25
|
+
const cancelReview = () => { if (inFlight.current) return; setReview(null); setBusyGate(false); };
|
|
26
|
+
const adopt = async () => {
|
|
27
|
+
if (inFlight.current || attempted || !review || me?.is_superuser !== true || !canManage) return;
|
|
28
|
+
inFlight.current = true; setBusy(true); setBusyGate(true); setAttempted(true); setError('');
|
|
29
|
+
try { const domain = await adoptHouseDomain({ domain: review }); await DomainModel.invalidate(queryClient); toast.success(`${domain.name} adopted at Platform scope`); close(); }
|
|
30
|
+
catch (reason) { await DomainModel.invalidate(queryClient); setError(`${redactRegistrarError(reason)} Review the domain inventory before another adoption.`); }
|
|
31
|
+
finally { inFlight.current = false; setBusy(false); setReview(null); setBusyGate(false); }
|
|
32
|
+
};
|
|
33
|
+
if (!me) return <div className="modal-pad dim">Checking DNS authority…</div>;
|
|
34
|
+
if (!canManage) return <div className="modal-pad domain-onboarding"><h2 className="modal-title">DNS management required</h2><p className="dim">A global DNS management permission is required.</p><button className="btn" onClick={close}>Close</button></div>;
|
|
35
|
+
if (mode === 'purchase') return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Buy domain</h2><DomainPurchaseWizard onDone={close} onBusyChange={setBusyGate} /></div>;
|
|
36
|
+
if (mode === 'register') return <DomainRegisterExisting close={close} back={() => setMode('menu')} setBusyGate={setBusyGate} />;
|
|
37
|
+
if (mode === 'adopt') {
|
|
38
|
+
if (me.is_superuser !== true) return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Platform superuser required</h2><button className="btn" onClick={close}>Close</button></div>;
|
|
39
|
+
return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Adopt a House domain</h2><p className="dim">Adopt one existing public hosted zone at Platform scope. No account discovery, new zone, or tenant assignment.</p>{error && <div className="form-alert" role="alert">{error}</div>}
|
|
40
|
+
{review ? <div className="dns-quote-card"><h3>Review exact adoption</h3><div><span>Domain</span><code>{review}</code></div><div><span>Scope</span><b>Platform</b></div><div className="modal-actions"><button className="btn" disabled={busy} onClick={cancelReview}>Cancel review</button><button className="btn btn-primary" disabled={busy} onClick={() => void adopt()}>{busy ? 'Adopting…' : 'Confirm adoption'}</button></div></div> : <><label className="field"><span className="field-label">Existing House domain</span><input className="input" value={name} disabled={busy || attempted} onChange={(event) => setName(event.target.value)} /></label><div className="modal-actions"><button className="btn" disabled={busy} onClick={attempted ? close : () => setMode('menu')}>{attempted ? 'Close and review inventory' : 'Back'}</button><button className="btn btn-primary" disabled={busy || attempted || !name.trim()} onClick={prepareAdoption}>Review adoption</button></div></>}
|
|
41
|
+
</div>;
|
|
42
|
+
}
|
|
43
|
+
return <div className="modal-pad domain-onboarding"><h2 className="modal-title">Add a domain</h2><p className="dim">Choose one explicit workflow.</p><div className="dns-onboarding-options"><button className="panel panel-pad" onClick={() => setMode('register')}><i className="bi bi-globe2" /><b>Register existing</b><span>Prove ownership of one GoDaddy domain using a verified group credential.</span></button>{me.is_superuser === true && <button className="panel panel-pad" onClick={() => setMode('adopt')}><i className="bi bi-house-add" /><b>Adopt a House domain</b><span>Type an existing public zone name; keep it at Platform scope.</span></button>}<button className="panel panel-pad" onClick={() => setMode('purchase')}><i className="bi bi-cart-check" /><b>Buy domain</b><span>Search, quote, confirm once, and reconcile against the durable ledger.</span></button></div><div className="modal-actions"><button className="btn" onClick={close}>Cancel</button></div></div>;
|
|
44
|
+
}
|
|
@@ -1,27 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { useMe } from '../../client/runtime';
|
|
4
|
-
import { modal, toast } from '../../ui';
|
|
5
|
-
import { adoptHouseDomain } from './api';
|
|
6
|
-
import { DomainModel } from './models';
|
|
7
|
-
import { normalizePurchaseDomain, redactRegistrarError } from './purchase-data';
|
|
8
|
-
import { DomainPurchaseWizard } from './DomainPurchaseWizard';
|
|
1
|
+
import { modal } from '../../ui';
|
|
2
|
+
import { DomainOnboardingDialog, type OnboardingMode } from './DomainOnboardingContent';
|
|
9
3
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const [mode, setMode] = useState<'menu' | 'purchase' | 'adopt'>('menu'); const [name, setName] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState('');
|
|
13
|
-
const adopt = async () => {
|
|
14
|
-
if (busy || me?.is_superuser !== true) return; setBusy(true); setBusyGate(true); setError('');
|
|
15
|
-
try { const domain = await adoptHouseDomain({ domain: normalizePurchaseDomain(name) }); await DomainModel.invalidate(queryClient); toast.success(`${domain.name} adopted at House scope`); close(); }
|
|
16
|
-
catch (reason) { setError(redactRegistrarError(reason)); }
|
|
17
|
-
finally { setBusy(false); setBusyGate(false); }
|
|
18
|
-
};
|
|
19
|
-
if (mode === 'purchase') return <div className="modal-pad"><h2 className="modal-title">Purchase a domain</h2><DomainPurchaseWizard onDone={close} onBusyChange={setBusyGate} /></div>;
|
|
20
|
-
if (mode === 'adopt') return <div className="modal-pad"><h2 className="modal-title">Adopt a House domain</h2><p className="dim">Manual, exact-name adoption only. This does not enumerate the House account and creates no tenant assignment.</p>{error && <div className="form-alert">{error}</div>}<label className="field"><span className="field-label">Existing House domain</span><input className="input" value={name} disabled={busy} onChange={(event) => setName(event.target.value)} /></label><div className="modal-actions"><button className="btn" disabled={busy} onClick={() => setMode('menu')}>Back</button><button className="btn btn-primary" disabled={busy || !name.trim()} onClick={() => void adopt()}>{busy ? 'Adopting…' : 'Adopt exact name'}</button></div></div>;
|
|
21
|
-
return <div className="modal-pad"><h2 className="modal-title">Add a domain</h2><p className="dim">Choose one explicit workflow.</p><div className="dns-onboarding-options"><button className="panel panel-pad" onClick={() => setMode('purchase')}><i className="bi bi-cart-check" /><b>Purchase a new domain</b><span>Search, quote, confirm once, and reconcile against the durable ledger.</span></button>{me?.is_superuser === true && <button className="panel panel-pad" onClick={() => setMode('adopt')}><i className="bi bi-house-add" /><b>Adopt a House domain</b><span>Type an existing name manually; no account discovery.</span></button>}</div><div className="modal-actions"><button className="btn" onClick={close}>Cancel</button></div></div>;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function showDomainOnboarding(): Promise<unknown> {
|
|
4
|
+
/** No-argument callers retain the original menu. */
|
|
5
|
+
export function showDomainOnboarding({ initialMode = 'menu' }: { initialMode?: OnboardingMode } = {}): Promise<unknown> {
|
|
25
6
|
let busy = false;
|
|
26
|
-
return modal.open((close) => <DomainOnboardingDialog close={() => close(null)} setBusyGate={(value) => { busy = value; }} />, { size: 'lg', canDismiss: () => !busy });
|
|
7
|
+
return modal.open((close) => <DomainOnboardingDialog initialMode={initialMode} close={() => close(null)} setBusyGate={(value) => { busy = value; }} />, { size: 'lg', canDismiss: () => !busy });
|
|
27
8
|
}
|
|
@@ -89,7 +89,7 @@ export function DomainPurchaseWizard({ onDone, onBusyChange }: { onDone?: () =>
|
|
|
89
89
|
inFlight.current = true; attempted.current = true; const accepted = ++generation.current;
|
|
90
90
|
const token = transient.token; quoteRef.current = null;
|
|
91
91
|
try {
|
|
92
|
-
const acknowledgement = await purchaseDomain({ group: transient.identity.group, purchase: transient.identity.purchase, confirm_token: token });
|
|
92
|
+
const acknowledgement = await purchaseDomain({ group: transient.identity.group, purchase: transient.identity.purchase, confirm_token: token, confirm_domain: transient.identity.domain, confirm_price: transient.identity.price });
|
|
93
93
|
const settled = await pollPurchaseLedger({ purchase: acknowledgement.purchase, isCurrent: () => generation.current === accepted, fetch: fetchPurchaseDetail });
|
|
94
94
|
if (!settled) setTerminalMessage(`Purchase acknowledgement was received, but bounded polling did not settle. ${PURCHASE_MAY_HAVE_MOVED}`);
|
|
95
95
|
else { setReceipt(settled); setTerminalMessage(settled.status === 'completed' ? 'Registration completed.' : `${redactRegistrarError(settled.error)} ${PURCHASE_MAY_HAVE_MOVED}`); }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { CollectionSelect, toast } from '../../ui';
|
|
4
|
+
import { OperationsHelp } from '../core/OperationsHelp';
|
|
5
|
+
import { DNS_GROUP_CHOICE_ENDPOINT, registerExistingDomain } from './api';
|
|
6
|
+
import { DnsCredentialModel, DomainModel, type DnsCredentialRow, type DnsGroupChoice } from './models';
|
|
7
|
+
import { normalizePurchaseDomain, redactRegistrarError } from './purchase-data';
|
|
8
|
+
|
|
9
|
+
export function usableRegistrationCredential(row: DnsCredentialRow | null, group: number): row is DnsCredentialRow {
|
|
10
|
+
return !!row && row.provider === 'godaddy' && row.is_active === true && row.verified === true
|
|
11
|
+
&& (typeof row.group === 'number' ? row.group : row.group?.id) === group;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DomainRegisterExisting({ close, back, setBusyGate }: { close: () => void; back: () => void; setBusyGate: (busy: boolean) => void }) {
|
|
15
|
+
const queryClient = useQueryClient();
|
|
16
|
+
const [group, setGroup] = useState<DnsGroupChoice | null>(null);
|
|
17
|
+
const [credential, setCredential] = useState<DnsCredentialRow | null>(null);
|
|
18
|
+
const [name, setName] = useState('');
|
|
19
|
+
const [busy, setBusy] = useState(false);
|
|
20
|
+
const [attempted, setAttempted] = useState(false);
|
|
21
|
+
const [error, setError] = useState('');
|
|
22
|
+
const inFlight = useRef(false);
|
|
23
|
+
// Both picker remounting and this ref reject responses/events from old scope.
|
|
24
|
+
const scope = useRef<number | null>(null);
|
|
25
|
+
const changeGroup = (selected: DnsGroupChoice | null) => {
|
|
26
|
+
if (inFlight.current || attempted) return;
|
|
27
|
+
scope.current = selected?.id ?? null; setGroup(selected); setCredential(null); setError('');
|
|
28
|
+
};
|
|
29
|
+
const submit = async (event: React.FormEvent) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
if (inFlight.current || attempted) return;
|
|
32
|
+
if (!group || !usableRegistrationCredential(credential, group.id) || scope.current !== group.id) { setError('Choose an active, verified GoDaddy credential in the selected group'); return; }
|
|
33
|
+
let domain: string;
|
|
34
|
+
try { domain = normalizePurchaseDomain(name); } catch (reason) { setError(redactRegistrarError(reason)); return; }
|
|
35
|
+
inFlight.current = true; setBusy(true); setBusyGate(true); setError('');
|
|
36
|
+
// An unknown outcome is terminal for this mounted form: inspect inventory.
|
|
37
|
+
setAttempted(true);
|
|
38
|
+
try {
|
|
39
|
+
const saved = await registerExistingDomain({ group: group.id, domain, credential: credential.id });
|
|
40
|
+
await DomainModel.invalidate(queryClient); toast.success(`${saved.name} registered`); close();
|
|
41
|
+
} catch (reason) {
|
|
42
|
+
await DomainModel.invalidate(queryClient);
|
|
43
|
+
setError(`${redactRegistrarError(reason)} Refresh the domain inventory and review the provider account before starting another registration.`);
|
|
44
|
+
} finally { inFlight.current = false; setBusy(false); setBusyGate(false); }
|
|
45
|
+
};
|
|
46
|
+
const groupId = group?.id;
|
|
47
|
+
return <form className="modal-pad domain-onboarding" onSubmit={(event) => void submit(event)} noValidate>
|
|
48
|
+
<h2 className="modal-title">Register existing domain</h2>
|
|
49
|
+
<p className="dim">Connect a GoDaddy domain you already own. <OperationsHelp label="domain registration">The provider checks ownership of this exact name without charging or listing the provider account. Choose an authorized group and an active, verified GoDaddy credential belonging to it. If no credentials appear, link and verify one in Provider Credentials.</OperationsHelp></p>
|
|
50
|
+
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
51
|
+
<div className="dns-registrar-grid">
|
|
52
|
+
<CollectionSelect<DnsGroupChoice> endpoint={DNS_GROUP_CHOICE_ENDPOINT} value={group} onChange={(_id, row) => changeGroup(row ?? null)} label="Authorized group" help="Choose the group that owns this domain." required maxItems={25} defaultParams={{ start: 0 }} disabled={busy || attempted} placeholder="Search eligible groups…" />
|
|
53
|
+
<label className="field"><span className="field-label">Domain name <em>*</em></span><input className="input" value={name} disabled={busy || attempted} onChange={(event) => setName(event.target.value)} placeholder="example.com" /></label>
|
|
54
|
+
{groupId ? <CollectionSelect<DnsCredentialRow> key={groupId} model={DnsCredentialModel} value={credential ? { ...credential } : null} label="GoDaddy credential" required maxItems={25} defaultParams={{ group: groupId, provider: 'godaddy', is_active: true, verified: true, start: 0 }} disabled={busy || attempted} placeholder="Search verified GoDaddy credentials…" help="An active, verified credential in the selected group." onChange={(_id, row) => {
|
|
55
|
+
if (scope.current !== groupId || inFlight.current || attempted) return;
|
|
56
|
+
if (!row) { setCredential(null); return; }
|
|
57
|
+
if (!usableRegistrationCredential(row, groupId)) { setCredential(null); setError('That credential is not usable in the selected group'); return; }
|
|
58
|
+
setCredential(row); setError('');
|
|
59
|
+
}} /> : <p className="dim">Choose an authorized group before selecting its GoDaddy credential.</p>}
|
|
60
|
+
</div>
|
|
61
|
+
<div className="modal-actions"><button className="btn" type="button" disabled={busy} onClick={attempted ? close : back}>{attempted ? 'Close and review inventory' : 'Back'}</button><button className="btn btn-primary" type="submit" disabled={busy || attempted || !group || !credential || !name.trim()}>{busy ? 'Checking ownership…' : 'Register existing'}</button></div>
|
|
62
|
+
</form>;
|
|
63
|
+
}
|