portal-mojo 0.2.21 → 0.2.22
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/admin-assistant.md +75 -23
- package/docs/admin-dns-domains.md +96 -90
- package/docs/admin-dns-records.md +1 -1
- package/docs/admin-dns-registrar.md +29 -4
- package/docs/admin-dns.md +2 -3
- package/docs/admin-modals.md +21 -0
- package/docs/menus-and-access.md +24 -0
- package/package.json +1 -1
- package/src/admin/assistant/AssistantFeed.tsx +7 -4
- package/src/admin/assistant/AssistantSetupPage.tsx +141 -64
- package/src/admin/assistant/api.ts +9 -7
- package/src/admin/assistant/pages.tsx +125 -109
- package/src/admin/assistant/setup.ts +1 -1
- package/src/admin/assistant/skill-lifecycle.ts +5 -4
- package/src/admin/core/index.ts +10 -2
- package/src/admin/dns/DnsRecordsPanel.tsx +45 -9
- package/src/admin/dns/DomainAssignment.tsx +55 -0
- package/src/admin/dns/DomainDetail.tsx +55 -38
- package/src/admin/dns/DomainOnboardingContent.tsx +9 -4
- package/src/admin/dns/DomainPurchaseDetail.tsx +42 -7
- package/src/admin/dns/DomainPurchaseWizard.tsx +102 -72
- package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
- package/src/admin/dns/DomainRegisterExisting.tsx +21 -6
- package/src/admin/dns/DomainRegistrarSettings.tsx +28 -48
- package/src/admin/dns/DomainRegistrationPanel.tsx +127 -0
- package/src/admin/dns/DomainsPage.tsx +25 -24
- package/src/admin/dns/ProviderCredentialsPage.tsx +32 -18
- package/src/admin/dns/RegistrantContactPage.tsx +73 -27
- package/src/admin/dns/api.ts +26 -25
- package/src/admin/dns/domain-authority.ts +11 -0
- package/src/admin/dns/index.ts +2 -2
- package/src/admin/dns/purchase-data.ts +5 -3
- package/src/admin/dns/registrar-api.ts +57 -11
- package/src/admin/domains/infrastructure.ts +8 -8
- package/src/admin/domains/operations.ts +6 -6
- package/src/admin/edge/VhostsPage.tsx +6 -1
- package/src/admin/jobs/JobDashboardPage.tsx +14 -4
- package/src/client/mock.ts +14 -8
- package/src/styles/components/admin-assistant.css +83 -20
- package/src/styles/components/admin-domains.css +67 -11
- package/src/styles/components/admin-edge.css +1 -0
- package/src/styles/components/admin-jobs.css +1 -0
- package/src/ui/SidebarNav.tsx +10 -1
- package/src/ui/menu-registry.ts +2 -0
|
@@ -1,42 +1,88 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { useMe } from '../../client/runtime';
|
|
3
|
-
import { Badge, CollectionSelect, modal } from '../../ui';
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { getAuthSessionRevision, getAuthSnapshot, hasPermission, subscribeAuth, useAuthSnapshot, useMe } from '../../client/runtime';
|
|
3
|
+
import { Badge, CollectionSelect, Tabs, modal } from '../../ui';
|
|
4
|
+
import { OperationsHelp } from '../core/OperationsHelp';
|
|
4
5
|
import { DNS_GROUP_CHOICE_ENDPOINT, clearRegistrantContact, fetchRegistrantContact, saveRegistrantContact } from './api';
|
|
5
|
-
import type
|
|
6
|
+
import { DNS_MANAGE_PERMISSIONS, type DnsGroupChoice, type RegistrantContact, type RegistrantContactResponse } from './models';
|
|
6
7
|
import { CONTACT_STRING_FIELDS, contactDraft, contactPayload, validateContactDraft } from './registrant-data';
|
|
7
8
|
import { redactRegistrarError } from './purchase-data';
|
|
8
9
|
|
|
9
10
|
type Scope = { kind: 'group'; group: DnsGroupChoice } | { kind: 'house' };
|
|
10
11
|
const LABELS: Record<(typeof CONTACT_STRING_FIELDS)[number], string> = { FirstName: 'First name', LastName: 'Last name', ContactType: 'Contact type', OrganizationName: 'Organization', AddressLine1: 'Address line 1', AddressLine2: 'Address line 2', City: 'City', State: 'State / province', CountryCode: 'Country code', ZipCode: 'Postal code', PhoneNumber: 'Phone number', Email: 'Email', Fax: 'Fax' };
|
|
12
|
+
const REQUIRED = new Set(['FirstName', 'LastName', 'ContactType', 'AddressLine1', 'City', 'CountryCode', 'ZipCode', 'PhoneNumber', 'Email']);
|
|
13
|
+
const ADDRESS = new Set(['AddressLine1', 'AddressLine2', 'City', 'State', 'CountryCode', 'ZipCode']);
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
/** Contact values stay in this mounted form, never in Query or browser storage. */
|
|
16
|
+
export function RegistrantContactPage({ initialGroup, lockScope = false, onSaved, onClose, onBusyChange }: { initialGroup?: DnsGroupChoice; lockScope?: boolean; onSaved?: () => void; onClose?: () => void; onBusyChange?: (busy: boolean) => void }) {
|
|
17
|
+
const { data: me } = useMe(); const auth = useAuthSnapshot();
|
|
18
|
+
const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
|
|
19
|
+
const canManage = auth.authenticated && hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
20
|
+
const identity = JSON.stringify([auth.uid, revision, canManage, initialGroup?.id, lockScope]);
|
|
21
|
+
const latest = useRef(identity); latest.current = identity;
|
|
22
|
+
const callbacks = useRef({ onSaved, onBusyChange }); callbacks.current = { onSaved, onBusyChange };
|
|
23
|
+
const [scope, setScope] = useState<Scope | null>(null);
|
|
24
|
+
const [loaded, setLoaded] = useState<{ identity: string; response: RegistrantContactResponse } | null>(null);
|
|
25
|
+
const [draft, setDraft] = useState<RegistrantContact>({});
|
|
26
|
+
const [busy, setBusy] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); const [notice, setNotice] = useState('');
|
|
27
|
+
const [tab, setTab] = useState('contact');
|
|
28
|
+
const generation = useRef(0); const mounted = useRef(false); const flight = useRef(false);
|
|
29
|
+
const response = loaded?.identity === identity && canManage ? loaded.response : null;
|
|
30
|
+
const pending = (value: boolean) => { flight.current = value; setBusy(value); callbacks.current.onBusyChange?.(value); };
|
|
31
|
+
const current = (accepted: number, owner: string, uid = auth.uid, rev = revision) => mounted.current && generation.current === accepted && latest.current === owner && getAuthSnapshot().uid === uid && getAuthSessionRevision() === rev;
|
|
32
|
+
const guard = (accepted: number, owner: string) => () => { if (!current(accepted, owner)) throw new Error('Registrant scope or session changed. Reopen this task.'); };
|
|
33
|
+
const accept = (value: RegistrantContactResponse, target: Scope) => {
|
|
34
|
+
const group = target.kind === 'group' ? target.group.id : null;
|
|
35
|
+
if (value.group !== group || value.scope !== (group == null ? 'global' : 'group')) throw new Error('The contact response did not match the selected scope');
|
|
36
|
+
setLoaded({ identity, response: value }); setDraft(contactDraft(value));
|
|
37
|
+
};
|
|
15
38
|
const choose = async (next: Scope | null) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
39
|
+
if (!canManage || flight.current || (next?.kind === 'house' && me?.is_superuser !== true)) return;
|
|
40
|
+
const accepted = ++generation.current, owner = identity;
|
|
41
|
+
setScope(next); setLoaded(null); setDraft({}); setError(''); setNotice(''); setTab('contact'); setLoading(!!next);
|
|
42
|
+
if (!next) return;
|
|
43
|
+
try { const value = await fetchRegistrantContact(next.kind === 'group' ? next.group.id : null, guard(accepted, owner)); if (current(accepted, owner)) accept(value, next); }
|
|
44
|
+
catch (reason) { if (current(accepted, owner)) setError(redactRegistrarError(reason)); }
|
|
45
|
+
finally { if (current(accepted, owner)) setLoading(false); }
|
|
21
46
|
};
|
|
47
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false; generation.current++; callbacks.current.onBusyChange?.(false); }; }, []);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
generation.current++; flight.current = false; setBusy(false); callbacks.current.onBusyChange?.(false);
|
|
50
|
+
setScope(null); setLoaded(null); setDraft({}); setError(''); setNotice(''); setLoading(false);
|
|
51
|
+
if (canManage && initialGroup) void choose({ kind: 'group', group: initialGroup });
|
|
52
|
+
// A session or explicit fixed-scope change discards all local PII.
|
|
53
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
54
|
+
}, [identity]);
|
|
22
55
|
const save = async () => {
|
|
23
|
-
if (!scope || !response ||
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
56
|
+
if (!canManage || !scope || !response || flight.current || loading) return;
|
|
57
|
+
const problems = validateContactDraft(draft);
|
|
58
|
+
if (problems.length) { setError(problems.join('; ')); setTab(problems.some(value => /FirstName|LastName|ContactType|PhoneNumber|Email/.test(value)) ? 'contact' : 'address'); return; }
|
|
59
|
+
const accepted = generation.current, owner = identity, target = scope; const group = target.kind === 'group' ? target.group.id : null;
|
|
60
|
+
const payload = contactPayload(draft, response, { group }); pending(true); setError(''); setNotice('');
|
|
61
|
+
try { const saved = await saveRegistrantContact(payload, group, guard(accepted, owner)); if (current(accepted, owner)) { accept(saved, target); setNotice('Contact saved.'); callbacks.current.onSaved?.(); } }
|
|
62
|
+
catch (reason) { if (current(accepted, owner)) setError(redactRegistrarError(reason)); }
|
|
63
|
+
finally { if (current(accepted, owner)) pending(false); }
|
|
28
64
|
};
|
|
29
65
|
const clear = async () => {
|
|
30
|
-
if (!scope || !response ||
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
66
|
+
if (!canManage || !scope || !response || flight.current || response.source !== 'database') return;
|
|
67
|
+
const accepted = generation.current, owner = identity, target = scope; pending(true); setError(''); setNotice('');
|
|
68
|
+
try {
|
|
69
|
+
if (!await modal.confirm({ title: 'Clear this contact?', message: `Remove the saved contact for ${target.kind === 'house' ? 'House' : target.group.name}. Any inherited contact will apply instead.`, confirmText: 'Clear contact', danger: true })) return;
|
|
70
|
+
if (!current(accepted, owner)) return;
|
|
71
|
+
const saved = await clearRegistrantContact(target.kind === 'group' ? target.group.id : null, guard(accepted, owner));
|
|
72
|
+
if (current(accepted, owner)) { accept(saved, target); setNotice('Saved contact cleared.'); }
|
|
73
|
+
} catch (reason) { if (current(accepted, owner)) setError(redactRegistrarError(reason)); }
|
|
74
|
+
finally { if (current(accepted, owner)) pending(false); }
|
|
36
75
|
};
|
|
37
|
-
return <div className="
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{
|
|
76
|
+
if (!canManage) return <div className="modal-pad dim" role="status">DNS management permission is required to manage registrant contacts.</div>;
|
|
77
|
+
const fields = (address: boolean) => <div className="form-grid">{CONTACT_STRING_FIELDS.filter(key => ADDRESS.has(key) === address).map(key => <div key={key} className={['AddressLine1', 'AddressLine2', 'OrganizationName'].includes(key) ? 'col-12' : 'col-6'}><label className="field"><span className="field-label">{LABELS[key]}{REQUIRED.has(key) && <em> *</em>}{key === 'PhoneNumber' && <OperationsHelp label="phone number">Use the international format +country.number, for example +1.2065550123.</OperationsHelp>}</span>{key === 'ContactType' ? <select className="input" value={String(draft[key] ?? '')} disabled={busy} onChange={event => setDraft(value => ({ ...value, [key]: event.target.value }))}><option value="">Select…</option>{['PERSON', 'COMPANY', 'ASSOCIATION', 'PUBLIC_BODY', 'RESELLER'].map(value => <option key={value}>{value}</option>)}</select> : <input className="input" aria-label={key === 'PhoneNumber' ? 'Phone number' : undefined} type={key === 'Email' ? 'email' : 'text'} value={String(draft[key] ?? '')} disabled={busy} onChange={event => setDraft(value => ({ ...value, [key]: event.target.value }))} />}</label></div>)}</div>;
|
|
78
|
+
return <div className={lockScope ? 'modal-pad dns-registrant-scoped' : 'dns-registrant-page'}>
|
|
79
|
+
<div className={lockScope ? 'dns-registrant-head' : 'panel panel-pad dns-registrant-head'}><div>{!lockScope && <div className="eyebrow">Infrastructure · DNS</div>}{lockScope ? <h2 className="modal-title">Registrant contact</h2> : <h1>Registrant Contact</h1>}<p className="dim">{lockScope ? initialGroup?.name : 'Choose whose contact to manage.'} <OperationsHelp label="registrant contact">The registrar requires accurate contact details to register a domain. Contact values are loaded only for the selected scope and remain in this open form.</OperationsHelp></p></div>{!lockScope && <div className="dns-scope-picker"><CollectionSelect<DnsGroupChoice> endpoint={DNS_GROUP_CHOICE_ENDPOINT} value={scope?.kind === 'group' ? scope.group : null} onChange={(_id, row) => void choose(row ? { kind: 'group', group: row } : null)} label="Group scope" placeholder="Search eligible groups…" maxItems={25} disabled={busy} defaultParams={{ start: 0 }} />{me?.is_superuser === true && <button className={`btn${scope?.kind === 'house' ? ' btn-primary' : ''}`} disabled={busy} onClick={() => void choose({ kind: 'house' })}><i className="bi bi-house" /> House contact</button>}</div>}</div>
|
|
80
|
+
{loading && <p className="dim" role="status">Loading contact…</p>}{error && <div className="form-alert" role="alert">{error}</div>}{notice && <p role="status">{notice}</p>}
|
|
81
|
+
{response && scope && <div className={lockScope ? 'dns-contact-card' : 'panel panel-pad dns-contact-card'}>
|
|
82
|
+
<div className="dns-contact-status"><Badge tone={response.effective_configured ? 'success' : 'warning'}>{response.effective_configured ? 'Ready for purchase' : 'Contact required'}</Badge><OperationsHelp label="contact source">{response.source === 'database' ? 'This scope has its own saved contact.' : response.inherited ? 'An inherited contact applies. Its values are hidden. Saving this form creates a separate contact for this group.' : response.source === 'settings_file' ? 'This contact comes from deployment configuration. Saving creates an override.' : 'No contact is configured.'}</OperationsHelp></div>
|
|
83
|
+
<Tabs ariaLabel="Registrant details" activeKey={tab} onActiveKeyChange={setTab} items={[{ key: 'contact', label: 'Contact', panel: fields(false) }, { key: 'address', label: 'Address', panel: fields(true) }]} />
|
|
84
|
+
{response.problems.length > 0 && <div className="form-alert">{response.problems.join('; ')}</div>}
|
|
85
|
+
<div className="modal-actions">{response.source === 'database' && <button className="btn btn-danger" disabled={busy} onClick={() => void clear()}>Clear this scope</button>}{onClose && <button className="btn" disabled={busy} onClick={onClose}>Close</button>}<button className="btn btn-primary" disabled={busy} onClick={() => void save()}>{busy ? 'Saving…' : 'Save contact'}</button></div>
|
|
86
|
+
</div>}{!response && onClose && <div className="modal-actions"><button className="btn" disabled={busy} onClick={onClose}>Close</button></div>}
|
|
41
87
|
</div>;
|
|
42
88
|
}
|
package/src/admin/dns/api.ts
CHANGED
|
@@ -181,32 +181,33 @@ export interface LinkDnsCredentialInput {
|
|
|
181
181
|
api_secret: string;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
async function submitCredential(body: Record<string, unknown
|
|
185
|
-
const response = await withFreshAuth(() => mojoCall('/api/dnsman/credential/link', {
|
|
186
|
-
method: 'POST', body,
|
|
187
|
-
}));
|
|
184
|
+
async function submitCredential(body: Record<string, unknown>, beforeSend?: () => void): Promise<DnsCredentialRow> {
|
|
185
|
+
const response = await withFreshAuth(() => { return mojoCall('/api/dnsman/credential/link', {
|
|
186
|
+
method: 'POST', body, beforeSend,
|
|
187
|
+
}); });
|
|
188
188
|
return sanitizeDnsCredentialRow(response.data as DnsCredentialRow);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
-
export async function linkDnsCredential(input: LinkDnsCredentialInput): Promise<DnsCredentialRow> {
|
|
191
|
+
export async function linkDnsCredential(input: LinkDnsCredentialInput, beforeSend?: () => void): Promise<DnsCredentialRow> {
|
|
192
192
|
if (!Number.isInteger(input.group) || input.group < 1) throw new Error('Choose a group');
|
|
193
|
-
return submitCredential({ ...input });
|
|
193
|
+
return submitCredential({ ...input }, beforeSend);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
export async function rotateDnsCredential(
|
|
197
197
|
row: DnsCredentialRow,
|
|
198
198
|
input: Pick<LinkDnsCredentialInput, 'name' | 'api_key' | 'api_secret'>,
|
|
199
|
+
beforeSend?: () => void,
|
|
199
200
|
): Promise<DnsCredentialRow> {
|
|
200
201
|
const group = typeof row.group === 'number' ? row.group : row.group?.id;
|
|
201
202
|
if (!group) throw new Error('This credential has no valid group');
|
|
202
203
|
return submitCredential({
|
|
203
204
|
group, provider: row.provider, credential: row.id,
|
|
204
205
|
name: input.name ?? row.name, api_key: input.api_key, api_secret: input.api_secret,
|
|
205
|
-
});
|
|
206
|
+
}, beforeSend);
|
|
206
207
|
}
|
|
207
208
|
|
|
208
|
-
async function postData<T>(path: string, body: Record<string, unknown
|
|
209
|
-
return (await mojoCall(path, { method: 'POST', body })).data as T;
|
|
209
|
+
async function postData<T>(path: string, body: Record<string, unknown>, beforeSend?: () => void): Promise<T> {
|
|
210
|
+
return (await mojoCall(path, { method: 'POST', body, beforeSend })).data as T;
|
|
210
211
|
}
|
|
211
212
|
|
|
212
213
|
export function searchRegistrar(domain: string): Promise<RegistrarSearchRow> {
|
|
@@ -222,14 +223,14 @@ export function suggestRegistrar(input: { domain: string; count?: number; only_a
|
|
|
222
223
|
}
|
|
223
224
|
|
|
224
225
|
/** Returns a one-use token; consumers must keep it transient and uncached. */
|
|
225
|
-
export function quoteDomain(input: { group: number; domain: string; years?: number }): Promise<RegistrarQuote> {
|
|
226
|
-
return postData('/api/dnsman/registrar/quote', input);
|
|
226
|
+
export function quoteDomain(input: { group: number; domain: string; years?: number }, beforeSend?: () => void): Promise<RegistrarQuote> {
|
|
227
|
+
return postData('/api/dnsman/registrar/quote', input, beforeSend);
|
|
227
228
|
}
|
|
228
229
|
|
|
229
|
-
export async function purchaseDomain(input: { group: number; purchase: number; confirm_token: string; confirm_domain: string; confirm_price: string }): Promise<RegistrarPurchaseResult> {
|
|
230
|
+
export async function purchaseDomain(input: { group: number; purchase: number; confirm_token: string; confirm_domain: string; confirm_price: string }, beforeSend?: () => void): Promise<RegistrarPurchaseResult> {
|
|
230
231
|
// Snapshot once: the safe 440 continuation must use the identical quote.
|
|
231
232
|
const body = Object.freeze({ ...input });
|
|
232
|
-
return sanitizePurchaseResult(await withFreshAuth(() => postData('/api/dnsman/registrar/purchase', body)));
|
|
233
|
+
return sanitizePurchaseResult(await withFreshAuth(() => postData('/api/dnsman/registrar/purchase', body, beforeSend)));
|
|
233
234
|
}
|
|
234
235
|
|
|
235
236
|
/** Detail and its error remain modal-local; neither enters Query cache. */
|
|
@@ -238,23 +239,23 @@ export async function fetchPurchaseDetail(id: number): Promise<DomainPurchaseRow
|
|
|
238
239
|
return sanitizeDomainPurchaseRow(response.data);
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
export async function fetchRegistrantContact(group: number | null): Promise<RegistrantContactResponse> {
|
|
242
|
-
const response = await mojoCall('/api/dnsman/registrant', { ...(group == null ? {} : { params: { group } }) });
|
|
242
|
+
export async function fetchRegistrantContact(group: number | null, beforeSend?: () => void): Promise<RegistrantContactResponse> {
|
|
243
|
+
const response = await mojoCall('/api/dnsman/registrant', { beforeSend, ...(group == null ? {} : { params: { group } }) });
|
|
243
244
|
return sanitizeRegistrantResponse(response.data);
|
|
244
245
|
}
|
|
245
246
|
|
|
246
|
-
export async function saveRegistrantContact(contact: RegistrantContact, group: number | null = null): Promise<RegistrantContactResponse> {
|
|
247
|
-
const response = await mojoCall('/api/dnsman/registrant', { method: 'POST', body: { ...(group == null ? {} : { group }), contact } });
|
|
247
|
+
export async function saveRegistrantContact(contact: RegistrantContact, group: number | null = null, beforeSend?: () => void): Promise<RegistrantContactResponse> {
|
|
248
|
+
const response = await mojoCall('/api/dnsman/registrant', { method: 'POST', beforeSend, body: { ...(group == null ? {} : { group }), contact } });
|
|
248
249
|
return sanitizeRegistrantResponse(response.data);
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
export async function clearRegistrantContact(group: number | null): Promise<RegistrantContactResponse> {
|
|
252
|
-
const response = await mojoCall('/api/dnsman/registrant', { method: 'POST', body: { ...(group == null ? {} : { group }), clear: true } });
|
|
252
|
+
export async function clearRegistrantContact(group: number | null, beforeSend?: () => void): Promise<RegistrantContactResponse> {
|
|
253
|
+
const response = await mojoCall('/api/dnsman/registrant', { method: 'POST', beforeSend, body: { ...(group == null ? {} : { group }), clear: true } });
|
|
253
254
|
return sanitizeRegistrantResponse(response.data);
|
|
254
255
|
}
|
|
255
256
|
|
|
256
|
-
export async function registerExistingDomain(input: { group: number; domain: string; credential: number }): Promise<DomainRow> {
|
|
257
|
-
return sanitizeDomainRow(await postData<DomainRow>('/api/dnsman/registrar/register-existing', input));
|
|
257
|
+
export async function registerExistingDomain(input: { group: number; domain: string; credential: number }, beforeSend?: () => void): Promise<DomainRow> {
|
|
258
|
+
return sanitizeDomainRow(await postData<DomainRow>('/api/dnsman/registrar/register-existing', input, beforeSend));
|
|
258
259
|
}
|
|
259
260
|
|
|
260
261
|
export async function discoverHouseDomains(untracked = false): Promise<RegistrarDiscoveryResponse> {
|
|
@@ -262,12 +263,12 @@ export async function discoverHouseDomains(untracked = false): Promise<Registrar
|
|
|
262
263
|
return sanitizeRegistrarDiscoveryResponse(response.data);
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
export async function adoptHouseDomain(input: { domain: string; group?: number; create_zone?: boolean }): Promise<DomainRow> {
|
|
266
|
-
return sanitizeDomainRow(await postData<DomainRow>('/api/dnsman/registrar/adopt', input));
|
|
266
|
+
export async function adoptHouseDomain(input: { domain: string; group?: number; create_zone?: boolean }, beforeSend?: () => void): Promise<DomainRow> {
|
|
267
|
+
return sanitizeDomainRow(await postData<DomainRow>('/api/dnsman/registrar/adopt', input, beforeSend));
|
|
267
268
|
}
|
|
268
269
|
|
|
269
|
-
export async function assignHouseDomain(input: { domain: number; group: number }): Promise<DomainRow> {
|
|
270
|
-
return sanitizeDomainRow(await postData<DomainRow>('/api/dnsman/registrar/assign-group', input));
|
|
270
|
+
export async function assignHouseDomain(input: { domain: number; group: number }, beforeSend?: () => void): Promise<DomainRow> {
|
|
271
|
+
return sanitizeDomainRow(await postData<DomainRow>('/api/dnsman/registrar/assign-group', input, beforeSend));
|
|
271
272
|
}
|
|
272
273
|
|
|
273
274
|
export function parseDnsRecordSetResponse(value: unknown): DnsRecordSetResponse {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { getAuthSessionRevision, getAuthSnapshot, hasPermission, mojoGet, type Me } from '../../client/runtime';
|
|
2
|
+
import { DNS_MANAGE_PERMISSIONS } from './models';
|
|
3
|
+
|
|
4
|
+
export const domainSession = () => `${getAuthSnapshot().uid}:${getAuthSessionRevision()}`;
|
|
5
|
+
|
|
6
|
+
/** Check the current interactive authority immediately before an onboarding write. */
|
|
7
|
+
export async function requireDomainManager(session: string, platform = false): Promise<void> {
|
|
8
|
+
if (domainSession() !== session || !getAuthSnapshot().authenticated) throw new Error('Session changed. Reopen this task.');
|
|
9
|
+
const me = await mojoGet<Me>('/api/user', 'me');
|
|
10
|
+
if (domainSession() !== session || !hasPermission(me, DNS_MANAGE_PERMISSIONS, null) || platform && me.is_superuser !== true) throw new Error('DNS authority changed. Reopen this task.');
|
|
11
|
+
}
|
package/src/admin/dns/index.ts
CHANGED
|
@@ -3,12 +3,12 @@ import './setup';
|
|
|
3
3
|
export * from './models';
|
|
4
4
|
export * from './api';
|
|
5
5
|
export * from './data';
|
|
6
|
-
export
|
|
6
|
+
export { ProviderCredentialsPage, dnsCredentialGroupId } from './ProviderCredentialsPage';
|
|
7
7
|
export * from './dns-data';
|
|
8
8
|
export * from './dns-integration';
|
|
9
9
|
export * from './domain-links';
|
|
10
10
|
export * from './DomainsPage';
|
|
11
|
-
export
|
|
11
|
+
export { DomainDetail } from './DomainDetail';
|
|
12
12
|
export * from './DnsRecordsPage';
|
|
13
13
|
export * from './DnsRecordsPanel';
|
|
14
14
|
export * from './DnsRecordEditor';
|
|
@@ -32,9 +32,9 @@ export function decimalTupleEqual(left: string | number, right: string | number)
|
|
|
32
32
|
return a != null && b != null && a === b;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export interface QuoteIdentity { purchase: number; group: number; domain: string; years: number; price: string; currency: string; expires: number }
|
|
35
|
+
export interface QuoteIdentity { purchase: number; group: number; domain: string; years: number; price: string; currency: string; expires: number; privacy_supported: boolean }
|
|
36
36
|
export function quoteIdentity(quote: RegistrarQuote, group: number): QuoteIdentity {
|
|
37
|
-
return { purchase: quote.purchase, group, domain: normalizePurchaseDomain(quote.name), years: quote.years, price: String(quote.price), currency: quote.currency, expires: quote.expires };
|
|
37
|
+
return { purchase: quote.purchase, group, domain: normalizePurchaseDomain(quote.name), years: quote.years, price: String(quote.price), currency: quote.currency, expires: quote.expires, privacy_supported: quote.privacy_supported };
|
|
38
38
|
}
|
|
39
39
|
export function quoteMatches(identity: QuoteIdentity, input: { group: number; domain: string; years: number; price: string | number; currency: string }, now = Date.now() / 1000): boolean {
|
|
40
40
|
return identity.group === input.group && identity.domain === normalizePurchaseDomain(input.domain) && identity.years === input.years
|
|
@@ -50,7 +50,7 @@ export function redactRegistrarError(error: unknown): string {
|
|
|
50
50
|
|
|
51
51
|
export function sanitizePurchaseResult(value: unknown): RegistrarPurchaseResult {
|
|
52
52
|
const row = value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
53
|
-
if (!Number.isInteger(row.purchase) || !Number.isInteger(row.domain) || typeof row.name !== 'string' || typeof row.status !== 'string') throw new Error('Registrar returned a malformed purchase acknowledgement');
|
|
53
|
+
if (!Number.isInteger(row.purchase) || Number(row.purchase) <= 0 || !Number.isInteger(row.domain) || Number(row.domain) <= 0 || typeof row.name !== 'string' || typeof row.status !== 'string') throw new Error('Registrar returned a malformed purchase acknowledgement');
|
|
54
54
|
return { purchase: Number(row.purchase), domain: Number(row.domain), name: normalizePurchaseDomain(row.name), status: row.status.slice(0, 32), operation_id: typeof row.operation_id === 'string' ? row.operation_id.slice(0, 128) : null, privacy: row.privacy === true, privacy_downgraded: row.privacy_downgraded === true };
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -61,6 +61,8 @@ export async function pollPurchaseLedger(options: { purchase: number; fetch: (id
|
|
|
61
61
|
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
62
62
|
if (options.isCurrent?.() === false) return null;
|
|
63
63
|
const row = await options.fetch(options.purchase);
|
|
64
|
+
if (options.isCurrent?.() === false) return null;
|
|
65
|
+
if (row.id !== options.purchase) throw new Error('The ledger response did not match this purchase');
|
|
64
66
|
if (purchaseTerminal(row)) return row;
|
|
65
67
|
}
|
|
66
68
|
return null;
|
|
@@ -1,13 +1,59 @@
|
|
|
1
1
|
import { mojoCall } from '../../client/runtime';
|
|
2
|
-
import { sanitizeDomainRow, type DomainRow } from './models';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
import { sanitizeDomainRow, type DomainRow, type RegistrantContact } from './models';
|
|
3
|
+
import { CONTACT_STRING_FIELDS, validateContactDraft } from './registrant-data';
|
|
4
|
+
export interface RegistrationRequest { signal?: AbortSignal; beforeSend?: () => void }
|
|
5
|
+
export interface DomainRegistration {
|
|
6
|
+
name: string; registrar: string | null; auto_renew: boolean | null;
|
|
7
|
+
privacy: boolean | null; admin_privacy: boolean | null; registrant_privacy: boolean | null; tech_privacy: boolean | null;
|
|
8
|
+
privacy_supported: boolean | null; expires: number | string | null;
|
|
9
|
+
registrant: RegistrantContact; admin: RegistrantContact; tech: RegistrantContact;
|
|
10
|
+
}
|
|
11
|
+
const object = (value: unknown): Record<string, unknown> => value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
12
|
+
const bool = (value: unknown) => typeof value === 'boolean' ? value : null;
|
|
13
|
+
const name = (value: string) => value.toLowerCase().replace(/\.+$/, '');
|
|
14
|
+
export function registrationAuthority(registrar: string | null): 'route53' | 'godaddy' | 'unknown' {
|
|
15
|
+
const value = (registrar ?? '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
16
|
+
if (['amazonregistrarinc', 'amazonregistrar', 'route53', 'amazonroute53'].includes(value)) return 'route53';
|
|
17
|
+
if (value.startsWith('godaddy')) return 'godaddy';
|
|
18
|
+
return 'unknown';
|
|
19
|
+
}
|
|
20
|
+
export function registrationContact(value: unknown): RegistrantContact {
|
|
21
|
+
const source = object(value), contact: RegistrantContact = {};
|
|
22
|
+
for (const key of CONTACT_STRING_FIELDS) if (typeof source[key] === 'string') contact[key] = source[key].slice(0, 500);
|
|
23
|
+
if (Array.isArray(source.ExtraParams)) contact.ExtraParams = source.ExtraParams.slice(0, 25).flatMap(value => {
|
|
24
|
+
const entry = object(value);
|
|
25
|
+
return typeof entry.Name === 'string' && typeof entry.Value === 'string' ? [{ Name: entry.Name.slice(0, 100), Value: entry.Value.slice(0, 500) }] : [];
|
|
26
|
+
});
|
|
27
|
+
return contact;
|
|
28
|
+
}
|
|
29
|
+
export async function fetchDomainRegistration(domain: Pick<DomainRow, 'id' | 'name'>, options: RegistrationRequest = {}): Promise<DomainRegistration> {
|
|
30
|
+
const row = object((await mojoCall('/api/dnsman/whois', { ...options, unscoped: true, params: { domain: domain.id } })).data);
|
|
31
|
+
options.beforeSend?.();
|
|
32
|
+
if (typeof row.name !== 'string' || name(row.name) !== name(domain.name)) throw new Error('Registration identity changed');
|
|
33
|
+
return { name: domain.name, registrar: typeof row.registrar === 'string' ? row.registrar.slice(0, 200) : null,
|
|
34
|
+
auto_renew: bool(row.auto_renew), privacy: bool(row.privacy), admin_privacy: bool(row.admin_privacy), registrant_privacy: bool(row.registrant_privacy), tech_privacy: bool(row.tech_privacy), privacy_supported: bool(row.privacy_supported),
|
|
35
|
+
expires: typeof row.expires === 'number' || typeof row.expires === 'string' ? row.expires : null,
|
|
36
|
+
registrant: registrationContact(row.registrant), admin: registrationContact(row.admin), tech: registrationContact(row.tech) };
|
|
37
|
+
}
|
|
38
|
+
/** The generic model hook is best-effort; only a subsequent WHOIS read is live evidence. */
|
|
39
|
+
export async function saveDomainRegistrarSettings(id: number, changes: Partial<Pick<DomainRow, 'auto_renew' | 'privacy'>>, options: RegistrationRequest = {}): Promise<void> {
|
|
40
|
+
if (!Object.keys(changes).length || Object.entries(changes).some(([key, value]) => !['auto_renew', 'privacy'].includes(key) || typeof value !== 'boolean')) throw new Error('Only changed boolean registrar settings may be saved');
|
|
41
|
+
await mojoCall(`/api/dnsman/domain/${id}`, { ...options, unscoped: true, method: 'POST', body: { ...changes } }); options.beforeSend?.();
|
|
42
|
+
}
|
|
43
|
+
async function registrarOperation(domain: Pick<DomainRow, 'id' | 'name'>, path: string, body: Record<string, unknown>, options: RegistrationRequest): Promise<string> {
|
|
44
|
+
const row = object((await mojoCall(path, { ...options, unscoped: true, method: 'POST', body: { domain: domain.id, ...body } })).data);
|
|
45
|
+
options.beforeSend?.();
|
|
46
|
+
if (typeof row.name !== 'string' || name(row.name) !== name(domain.name) || typeof row.operation_id !== 'string' || !row.operation_id) throw new Error('Registrar receipt could not be confirmed');
|
|
47
|
+
return row.operation_id.slice(0, 200);
|
|
48
|
+
}
|
|
49
|
+
export const saveRegistrationPrivacy = (domain: Pick<DomainRow, 'id' | 'name'>, enabled: boolean, options: RegistrationRequest = {}) => registrarOperation(domain, '/api/dnsman/whois/privacy', { enabled }, options);
|
|
50
|
+
export function saveRegistrationContacts(domain: Pick<DomainRow, 'id' | 'name'>, contact: RegistrantContact, options: RegistrationRequest = {}) {
|
|
51
|
+
const safe = registrationContact(contact);
|
|
52
|
+
if (validateContactDraft(safe).length) throw new Error('Complete the required contact and address fields');
|
|
53
|
+
return registrarOperation(domain, '/api/dnsman/whois', { contact: safe }, options);
|
|
54
|
+
}
|
|
55
|
+
/** Safe model projection: no registrar PII enters the model cache. */
|
|
56
|
+
export async function fetchDomainOverview(id: number, options: RegistrationRequest = {}): Promise<DomainRow> {
|
|
57
|
+
const row = sanitizeDomainRow((await mojoCall(`/api/dnsman/domain/${id}`, { ...options, unscoped: true, params: { graph: 'default' } })).data);
|
|
58
|
+
options.beforeSend?.(); if (row.id !== id) throw new Error('Domain identity changed'); return row;
|
|
13
59
|
}
|
|
@@ -5,18 +5,18 @@ import { BUCKET_MANAGE_PERMS, STORAGE_VIEW_PERMS } from '../storage/permissions'
|
|
|
5
5
|
import { SETTINGS_PERMISSIONS } from '../settings/model';
|
|
6
6
|
|
|
7
7
|
export const DNS_ADMIN_SECTION: AdminSection = {
|
|
8
|
-
id: 'dns', title: 'DNS', icon: 'bi-globe2', navigationGroup: '
|
|
8
|
+
id: 'dns', title: 'DNS', icon: 'bi-globe2', navigationGroup: 'hosting', basePath: 'dns', permissions: DNS_VIEW_PERMISSIONS,
|
|
9
9
|
routes: [
|
|
10
10
|
{ path: 'domains', label: 'Domains', loadComponent: () => import('../dns/DomainsPage').then(namedPage('DomainsPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
11
|
-
{ path: 'records', label: 'DNS Records', loadComponent: () => import('../dns/DnsRecordsPage').then(namedPage('DnsRecordsPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
11
|
+
{ path: 'records', label: 'DNS Records', searchOnly: true, loadComponent: () => import('../dns/DnsRecordsPage').then(namedPage('DnsRecordsPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
12
12
|
{ path: 'certificates', label: 'Certificates', loadComponent: () => import('../dns/CertificatesPage').then(namedPage('CertificatesPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
13
|
-
{ path: 'purchases', label: 'Domain Purchases', loadComponent: () => import('../dns/DomainPurchasesPage').then(namedPage('DomainPurchasesPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
14
|
-
{ path: 'registrant', label: 'Registrant Contact', loadComponent: () => import('../dns/RegistrantContactPage').then(namedPage('RegistrantContactPage')), permissions: DNS_MANAGE_PERMISSIONS },
|
|
15
|
-
{ path: 'credentials', label: 'Provider Credentials', loadComponent: () => import('../dns/ProviderCredentialsPage').then(namedPage('ProviderCredentialsPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
13
|
+
{ path: 'purchases', label: 'Domain Purchases', searchOnly: true, loadComponent: () => import('../dns/DomainPurchasesPage').then(namedPage('DomainPurchasesPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
14
|
+
{ path: 'registrant', label: 'Registrant Contact', searchOnly: true, loadComponent: () => import('../dns/RegistrantContactPage').then(namedPage('RegistrantContactPage')), permissions: DNS_MANAGE_PERMISSIONS },
|
|
15
|
+
{ path: 'credentials', label: 'Provider Credentials', searchOnly: true, loadComponent: () => import('../dns/ProviderCredentialsPage').then(namedPage('ProviderCredentialsPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
16
16
|
],
|
|
17
17
|
};
|
|
18
18
|
export const STORAGE_ADMIN_SECTION: AdminSection = {
|
|
19
|
-
id: 'storage', basePath: 'storage', title: 'Storage', icon: 'bi-hdd-stack', navigationGroup: '
|
|
19
|
+
id: 'storage', basePath: 'storage', title: 'Storage', icon: 'bi-hdd-stack', navigationGroup: 'storage', permissions: ['sys.view_fileman', 'sys.manage_files', 'sys.manage_aws', 'sys.files', ...SETTINGS_PERMISSIONS],
|
|
20
20
|
routes: [
|
|
21
21
|
{ path: 'buckets', label: 'Buckets', loadComponent: () => import('../storage/BucketsPage').then(namedPage('BucketsPage')), permissions: BUCKET_MANAGE_PERMS },
|
|
22
22
|
{ path: 'backends', label: 'Backends', loadComponent: () => import('../storage/BackendsPage').then(namedPage('BackendsPage')), permissions: STORAGE_VIEW_PERMS },
|
|
@@ -26,10 +26,10 @@ export const STORAGE_ADMIN_SECTION: AdminSection = {
|
|
|
26
26
|
],
|
|
27
27
|
};
|
|
28
28
|
export const EDGE_ADMIN_SECTION: AdminSection = {
|
|
29
|
-
id: 'edge', basePath: 'edge', title: 'Edge', icon: 'bi-hdd-network', navigationGroup: '
|
|
29
|
+
id: 'edge', basePath: 'edge', title: 'Edge', icon: 'bi-hdd-network', navigationGroup: 'hosting', permissions: DNS_VIEW_PERMISSIONS,
|
|
30
30
|
routes: [
|
|
31
31
|
{ path: 'vhosts', label: 'Serving', loadComponent: () => import('../edge/VhostsPage').then(namedPage('VhostsPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
32
|
-
{ path: 'upstreams', label: 'Upstreams', loadComponent: () => import('../edge/UpstreamsPage').then(namedPage('UpstreamsPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
32
|
+
{ path: 'upstreams', label: 'Upstreams', searchOnly: true, loadComponent: () => import('../edge/UpstreamsPage').then(namedPage('UpstreamsPage')), permissions: DNS_VIEW_PERMISSIONS },
|
|
33
33
|
],
|
|
34
34
|
};
|
|
35
35
|
export const FLEET_ADMIN_SECTION: AdminSection = {
|
|
@@ -6,7 +6,7 @@ import { SETTINGS_PERMISSIONS } from '../settings/model';
|
|
|
6
6
|
const navigationGroup = 'operations';
|
|
7
7
|
|
|
8
8
|
export const SETTINGS_ADMIN_SECTION: AdminSection = {
|
|
9
|
-
id: 'settings', title: 'Settings', icon: 'bi-gear', navigationGroup, permissions: SETTINGS_PERMISSIONS,
|
|
9
|
+
id: 'settings', title: 'Settings', icon: 'bi-gear', navigationGroup: 'configuration', permissions: SETTINGS_PERMISSIONS,
|
|
10
10
|
routes: [{ path: '', loadComponent: () => import('../settings/SettingsPage').then(namedPage('SettingsPage')), permissions: SETTINGS_PERMISSIONS }],
|
|
11
11
|
};
|
|
12
12
|
// Empty any-of permissions admit only the existing literal-superuser bypass.
|
|
@@ -14,14 +14,14 @@ export const SETTINGS_ADMIN_SECTION: AdminSection = {
|
|
|
14
14
|
export const FLEET_CONFIGURATION_ADMIN_SECTION: AdminSection = {
|
|
15
15
|
id: 'fleet-configuration', title: 'Fleet Configuration', icon: 'bi-hdd-network',
|
|
16
16
|
navigationGroup, permissions: [],
|
|
17
|
-
routes: ['', 'shared-validation'].map((path) => ({ path, requireSuperuser: true, loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(namedPage('FleetConfigurationPage')) })),
|
|
17
|
+
routes: ['', 'shared-validation'].map((path) => ({ path, ...(path === '' ? { label: 'Fleet Configuration', searchOnly: true } : {}), requireSuperuser: true, loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(namedPage('FleetConfigurationPage')) })),
|
|
18
18
|
};
|
|
19
19
|
export const JOBS_ADMIN_SECTION: AdminSection = {
|
|
20
20
|
id: 'jobs', title: 'Jobs', icon: 'bi-cpu', navigationGroup, permissions: [...new Set([...JOBS_VIEW_PERMS, ...SCHEDULED_TASK_VIEW_PERMS])],
|
|
21
21
|
routes: [
|
|
22
|
-
{ path: '', label: 'Dashboard', loadComponent: () => import('../jobs/JobDashboardPage').then(namedPage('JobDashboardPage')), permissions: JOBS_VIEW_PERMS },
|
|
23
|
-
{ path: 'runners', label: 'Runners', loadComponent: () => import('../jobs/JobRunnersPage').then(namedPage('JobRunnersPage')), permissions: JOBS_VIEW_PERMS },
|
|
24
|
-
{ path: 'list', label: 'Jobs', loadComponent: () => import('../jobs/JobsTablePage').then(namedPage('JobsTablePage')), permissions: JOBS_VIEW_PERMS },
|
|
22
|
+
{ path: '', label: 'Jobs Dashboard', loadComponent: () => import('../jobs/JobDashboardPage').then(namedPage('JobDashboardPage')), permissions: JOBS_VIEW_PERMS },
|
|
23
|
+
{ path: 'runners', label: 'Job Runners', searchOnly: true, loadComponent: () => import('../jobs/JobRunnersPage').then(namedPage('JobRunnersPage')), permissions: JOBS_VIEW_PERMS },
|
|
24
|
+
{ path: 'list', label: 'Jobs', searchOnly: true, loadComponent: () => import('../jobs/JobsTablePage').then(namedPage('JobsTablePage')), permissions: JOBS_VIEW_PERMS },
|
|
25
25
|
{ path: 'scheduled-tasks', label: 'Scheduled Tasks', loadComponent: () => import('../jobs/ScheduledTasksPage').then(namedPage('ScheduledTasksPage')), permissions: SCHEDULED_TASK_VIEW_PERMS },
|
|
26
26
|
],
|
|
27
27
|
};
|
|
@@ -36,7 +36,7 @@ export const MAINTENANCE_ADMIN_SECTION: AdminSection = {
|
|
|
36
36
|
routes: [{ path: '', label: 'Maintenance', loadComponent: () => import('../maintenance/MaintenancePage').then(namedPage('MaintenancePage')) }],
|
|
37
37
|
};
|
|
38
38
|
export const GEOLOCATION_ADMIN_SECTION: AdminSection = {
|
|
39
|
-
id: 'geolocation', title: 'GeoLocation', icon: 'bi-geo-alt', navigationGroup, permissions: [],
|
|
39
|
+
id: 'geolocation', title: 'GeoLocation', icon: 'bi-geo-alt', navigationGroup: 'configuration', permissions: [],
|
|
40
40
|
routes: [{ path: '', label: 'GeoLocation', requireSuperuser: true, loadComponent: () => import('../geolocation/GeoLocationPage').then(namedPage('GeoLocationPage')) }],
|
|
41
41
|
};
|
|
42
42
|
export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION, MAINTENANCE_ADMIN_SECTION, DEPLOYMENTS_ADMIN_SECTION, GEOLOCATION_ADMIN_SECTION] as const;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
4
|
+
import { useAdminRouteContext } from '../core/route-context';
|
|
3
5
|
import { Badge, CollectionSelect, ModelTable, modal, toast, type Column, type FilterDef } from '../../ui';
|
|
4
6
|
import { getAuthSessionRevision, useAuthSnapshot, useCan, useTableParams } from '../../client/runtime';
|
|
5
7
|
import { DomainModel, type DomainRow } from '../dns/models';
|
|
@@ -19,6 +21,9 @@ const FILTERS: FilterDef[] = [
|
|
|
19
21
|
];
|
|
20
22
|
|
|
21
23
|
export function VhostsPage() {
|
|
24
|
+
const route = useAdminRouteContext(), location = useLocation();
|
|
25
|
+
const upstreams = location.pathname === '/' ? '/edge/upstreams' : `${location.pathname.replace(/\/[^/]+\/?$/, '')}/upstreams`;
|
|
26
|
+
const upstreamsMounted = !route || route.paths.includes(upstreams);
|
|
22
27
|
const queryClient = useQueryClient();
|
|
23
28
|
const [revision, setRevision] = useState(0);
|
|
24
29
|
const changed = () => setRevision(value => value + 1);
|
|
@@ -42,5 +47,5 @@ export function VhostsPage() {
|
|
|
42
47
|
const saved = await openVhostWizard(null, { signal });
|
|
43
48
|
if (saved && !signal?.aborted) { changed(); toast.success(`${saved.server_name ?? 'Hostname'} configuration saved${saved.is_enabled ? ', enabled intent' : ', disabled'}.`); await open(saved); }
|
|
44
49
|
};
|
|
45
|
-
return <div className="edge-serving-page"><header className="page-header edge-serving-heading"><div><div className="eyebrow">Network & hosting</div><h1>Serving</h1><ServingReadiness refreshRevision={revision} /></div>{canManage && <button type="button" className="btn btn-primary" onClick={() => void create()}><i className="bi bi-plus-lg" /> Add hostname</button>}</header><div className="edge-domain-filter"><CollectionSelect<DomainRow> model={DomainModel} value={domain ? Number(domain) : null} label="Domain" placeholder="All domains" defaultParams={{ sort: 'name' }} onChange={id => params.setFilter('domain', id == null ? null : String(id))} /></div><ModelTable<EdgeVhostRow> model={EdgeVhostModel} title="Hostnames" searchable searchPlaceholder="Search label, type or pool" columns={COLUMNS} filters={FILTERS} defaultSort="label" columnChooser persistState persistKey="admin:edge:vhosts" onRowClick={row => void open(row)} /></div>;
|
|
50
|
+
return <div className="edge-serving-page"><header className="page-header edge-serving-heading"><div><div className="eyebrow">Network & hosting</div><h1>Serving</h1><ServingReadiness refreshRevision={revision} /></div><div className="edge-serving-actions">{upstreamsMounted && <Link className="btn" to={upstreams}>Upstreams</Link>}{canManage && <button type="button" className="btn btn-primary" onClick={() => void create()}><i className="bi bi-plus-lg" /> Add hostname</button>}</div></header><div className="edge-domain-filter"><CollectionSelect<DomainRow> model={DomainModel} value={domain ? Number(domain) : null} label="Domain" placeholder="All domains" defaultParams={{ sort: 'name' }} onChange={id => params.setFilter('domain', id == null ? null : String(id))} /></div><ModelTable<EdgeVhostRow> model={EdgeVhostModel} title="Hostnames" searchable searchPlaceholder="Search label, type or pool" columns={COLUMNS} filters={FILTERS} defaultSort="label" columnChooser persistState persistKey="admin:edge:vhosts" onRowClick={row => void open(row)} /></div>;
|
|
46
51
|
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
// returning), and web-mojo's JobHealthView — the only consumer — was never
|
|
9
9
|
// mounted. Stats already carries channels, runners, totals and the scheduler.
|
|
10
10
|
import { useQueryClient } from '@tanstack/react-query';
|
|
11
|
-
import { useLocation, useNavigate } from 'react-router-dom';
|
|
11
|
+
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
|
12
|
+
import { hasPermission, useMe } from '../../client/runtime';
|
|
13
|
+
import { useAdminRouteContext } from '../core/route-context';
|
|
14
|
+
import { JOBS_VIEW_PERMS, SCHEDULED_TASK_VIEW_PERMS } from './models';
|
|
12
15
|
import { KPITile } from '../../charts';
|
|
13
16
|
import { JobChannelsPanel } from './sections/JobChannelsPanel';
|
|
14
17
|
import { JobOperationsSection } from './sections/JobOperationsSection';
|
|
@@ -21,18 +24,24 @@ export function JobDashboardPage() {
|
|
|
21
24
|
const stats = useJobStats();
|
|
22
25
|
const channelOptions = useChannelOptions();
|
|
23
26
|
const navigate = useNavigate();
|
|
27
|
+
const { data: me } = useMe();
|
|
28
|
+
const route = useAdminRouteContext();
|
|
24
29
|
const location = useLocation();
|
|
30
|
+
const base = location.pathname.replace(/\/+$/, '') || '/jobs';
|
|
31
|
+
const related = [
|
|
32
|
+
{ path: 'list', label: 'Jobs', permissions: JOBS_VIEW_PERMS },
|
|
33
|
+
{ path: 'runners', label: 'Runners', permissions: JOBS_VIEW_PERMS },
|
|
34
|
+
{ path: 'scheduled-tasks', label: 'Scheduled tasks', permissions: SCHEDULED_TASK_VIEW_PERMS },
|
|
35
|
+
].filter(item => hasPermission(me ?? null, item.permissions, null) && (!route || route.paths.includes(`${base}/${item.path}`)));
|
|
25
36
|
|
|
26
37
|
const totals = stats.data?.totals;
|
|
27
38
|
const refreshAll = () => { void queryClient.invalidateQueries({ queryKey: jobsKeys.root }); };
|
|
28
39
|
|
|
29
40
|
/**
|
|
30
41
|
* Open the jobs table already filtered to a channel. The path is derived
|
|
31
|
-
* from
|
|
32
|
-
* section never knows whether it is mounted at the root or under /system.
|
|
42
|
+
* from this dashboard location, including custom embedded base paths.
|
|
33
43
|
*/
|
|
34
44
|
const openChannel = (channel: string) => {
|
|
35
|
-
const base = location.pathname.replace(/\/+$/, '');
|
|
36
45
|
navigate({ pathname: `${base}/list`, search: `?channel=${encodeURIComponent(channel)}` });
|
|
37
46
|
};
|
|
38
47
|
|
|
@@ -65,6 +74,7 @@ export function JobDashboardPage() {
|
|
|
65
74
|
<i className={`bi bi-arrow-clockwise${stats.isFetching ? ' spin' : ''}`} /> Refresh
|
|
66
75
|
</button>
|
|
67
76
|
</header>
|
|
77
|
+
{related.length > 0 && <nav className="jobs-page-links" aria-label="Job tools">{related.map(item => <Link className="btn btn-compact" key={item.path} to={`${base}/${item.path}`}>{item.label}</Link>)}</nav>}
|
|
68
78
|
|
|
69
79
|
{stats.isError && (
|
|
70
80
|
<div className="panel jobs-note jobs-note-bad">
|