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,8 +1,9 @@
|
|
|
1
|
-
import { useRef, useState } from 'react';
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useQueryClient } from '@tanstack/react-query';
|
|
3
3
|
import { CollectionSelect, toast } from '../../ui';
|
|
4
4
|
import { OperationsHelp } from '../core/OperationsHelp';
|
|
5
|
-
import { DNS_GROUP_CHOICE_ENDPOINT, registerExistingDomain } from './api';
|
|
5
|
+
import { DNS_GROUP_CHOICE_ENDPOINT, fetchDnsCapabilities, registerExistingDomain } from './api';
|
|
6
|
+
import { domainSession, requireDomainManager } from './domain-authority';
|
|
6
7
|
import { DnsCredentialModel, DomainModel, type DnsCredentialRow, type DnsGroupChoice } from './models';
|
|
7
8
|
import { normalizePurchaseDomain, redactRegistrarError } from './purchase-data';
|
|
8
9
|
|
|
@@ -22,6 +23,9 @@ export function DomainRegisterExisting({ close, back, setBusyGate }: { close: ()
|
|
|
22
23
|
const inFlight = useRef(false);
|
|
23
24
|
// Both picker remounting and this ref reject responses/events from old scope.
|
|
24
25
|
const scope = useRef<number | null>(null);
|
|
26
|
+
const session = useRef(domainSession());
|
|
27
|
+
const mounted = useRef(true);
|
|
28
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
25
29
|
const changeGroup = (selected: DnsGroupChoice | null) => {
|
|
26
30
|
if (inFlight.current || attempted) return;
|
|
27
31
|
scope.current = selected?.id ?? null; setGroup(selected); setCredential(null); setError('');
|
|
@@ -34,9 +38,12 @@ export function DomainRegisterExisting({ close, back, setBusyGate }: { close: ()
|
|
|
34
38
|
try { domain = normalizePurchaseDomain(name); } catch (reason) { setError(redactRegistrarError(reason)); return; }
|
|
35
39
|
inFlight.current = true; setBusy(true); setBusyGate(true); setError('');
|
|
36
40
|
// An unknown outcome is terminal for this mounted form: inspect inventory.
|
|
37
|
-
setAttempted(true);
|
|
38
41
|
try {
|
|
39
|
-
|
|
42
|
+
await requireDomainManager(session.current);
|
|
43
|
+
if (!mounted.current) return;
|
|
44
|
+
setAttempted(true);
|
|
45
|
+
const saved = await registerExistingDomain({ group: group.id, domain, credential: credential.id }, () => { if (!mounted.current || domainSession() !== session.current || scope.current !== group.id) throw new Error('Session or group changed. Reopen this task.'); });
|
|
46
|
+
if (!mounted.current || domainSession() !== session.current) return;
|
|
40
47
|
await DomainModel.invalidate(queryClient); toast.success(`${saved.name} registered`); close();
|
|
41
48
|
} catch (reason) {
|
|
42
49
|
await DomainModel.invalidate(queryClient);
|
|
@@ -45,7 +52,7 @@ export function DomainRegisterExisting({ close, back, setBusyGate }: { close: ()
|
|
|
45
52
|
};
|
|
46
53
|
const groupId = group?.id;
|
|
47
54
|
return <form className="modal-pad domain-onboarding" onSubmit={(event) => void submit(event)} noValidate>
|
|
48
|
-
<h2 className="modal-title">
|
|
55
|
+
<h2 className="modal-title">Connect GoDaddy domain</h2>
|
|
49
56
|
<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
57
|
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
51
58
|
<div className="dns-registrar-grid">
|
|
@@ -57,7 +64,15 @@ export function DomainRegisterExisting({ close, back, setBusyGate }: { close: ()
|
|
|
57
64
|
if (!usableRegistrationCredential(row, groupId)) { setCredential(null); setError('That credential is not usable in the selected group'); return; }
|
|
58
65
|
setCredential(row); setError('');
|
|
59
66
|
}} /> : <p className="dim">Choose an authorized group before selecting its GoDaddy credential.</p>}
|
|
67
|
+
{group && <button className="btn domains-inline-action" type="button" disabled={busy || attempted} onClick={() => void (async () => {
|
|
68
|
+
if (inFlight.current) return;
|
|
69
|
+
inFlight.current = true; setBusy(true); setBusyGate(true);
|
|
70
|
+
const selected = group.id;
|
|
71
|
+
try { const caps = await fetchDnsCapabilities(selected); const { showCredentialEditor } = await import('./ProviderCredentialsPage'); if (!mounted.current || scope.current !== selected || domainSession() !== session.current) return; const linked = await showCredentialEditor({ ...caps, providers: caps.providers.filter(provider => provider.name === 'godaddy') }, undefined, group); if (mounted.current && scope.current === selected && domainSession() === session.current && linked && usableRegistrationCredential(linked, selected)) setCredential(linked); }
|
|
72
|
+
catch (reason) { if (scope.current === selected) setError(redactRegistrarError(reason)); }
|
|
73
|
+
finally { inFlight.current = false; if (mounted.current) setBusy(false); setBusyGate(false); }
|
|
74
|
+
})()}><i className="bi bi-key" /> Link credential</button>}
|
|
60
75
|
</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…' : '
|
|
76
|
+
<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…' : 'Connect domain'}</button></div>
|
|
62
77
|
</form>;
|
|
63
78
|
}
|
|
@@ -1,52 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (autoRenew !== (live.auto_renew === true)) changes.auto_renew = autoRenew;
|
|
22
|
-
if (privacy !== (live.privacy === true)) changes.privacy = privacy;
|
|
23
|
-
inFlight.current = true; onBusyChange?.(true); setBusy(true); setError(''); setSaved(false);
|
|
24
|
-
let failed = false;
|
|
25
|
-
try { await saveDomainRegistrarSettings(domain.id, changes); }
|
|
26
|
-
catch (reason) { failed = true; setUncertain(true); setError(`${redactRegistrarError(reason)} The registrar outcome is uncertain. Inspect the registrar before another change.`); }
|
|
27
|
-
finally {
|
|
28
|
-
try {
|
|
29
|
-
const fresh = await fetchDomainOverview(domain.id);
|
|
30
|
-
queryClient.setQueryData(DomainModel.keys.one(domain.id), fresh);
|
|
31
|
-
setLive(fresh); setAutoRenew(fresh.auto_renew === true); setPrivacy(fresh.privacy === true);
|
|
32
|
-
if (!failed) setSaved(true);
|
|
33
|
-
} catch { setUncertain(true); setError('The authoritative domain could not be refreshed. Inspect the registrar and reopen this domain before making another change.'); }
|
|
34
|
-
await DomainModel.invalidate(queryClient);
|
|
35
|
-
inFlight.current = false; onBusyChange?.(false); setBusy(false);
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
return <form className="modal-pad domains-registrar-settings" onSubmit={(event) => void submit(event)}>
|
|
39
|
-
<h2 className="modal-title">Registrar settings</h2><p className="dim">Route 53 only. A database save does not prove that the registrar applied the change.</p>
|
|
40
|
-
{error && <div className="form-alert" role="alert">{error}</div>}
|
|
41
|
-
{live.last_error && <div className="form-alert" role="status"><b>Registrar warning</b><p>{redactRegistrarError(live.last_error)}</p><p>This may be historical. Inspect the registrar before assuming the setting was applied.</p></div>}
|
|
42
|
-
{saved && <p role="status">Domain refreshed after save. Check the registrar for application of these settings.</p>}
|
|
43
|
-
<label className="field"><span><input type="checkbox" checked={autoRenew} disabled={busy || uncertain} onChange={(event) => setAutoRenew(event.target.checked)} /> Auto-renew</span></label>
|
|
44
|
-
<label className="field"><span><input type="checkbox" checked={privacy} disabled={busy || uncertain} onChange={(event) => setPrivacy(event.target.checked)} /> WHOIS privacy</span></label>
|
|
45
|
-
<div className="modal-actions">{close && <button className="btn" type="button" disabled={busy} onClick={close}>Close</button>}<button className="btn btn-primary" type="submit" disabled={busy || uncertain || !changed}>{busy ? 'Saving and refreshing…' : 'Save registrar settings'}</button></div>
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { modal } from '../../ui/modal';
|
|
3
|
+
import type { DomainRow } from './models';
|
|
4
|
+
import { registrationAuthority, type DomainRegistration } from './registrar-api';
|
|
5
|
+
export interface RegistrarChanges { auto_renew?: boolean; privacy?: boolean }
|
|
6
|
+
export function DomainRegistrarSettings({ domain, registration, busy = false, locked = false, error, notice, submit, close }: {
|
|
7
|
+
domain: DomainRow; registration?: DomainRegistration; busy?: boolean; locked?: boolean; error?: string; notice?: string;
|
|
8
|
+
submit?: (changes: RegistrarChanges) => void; close?: () => void;
|
|
9
|
+
}) {
|
|
10
|
+
const [autoRenew, setAutoRenew] = useState(registration?.auto_renew ?? null), [privacy, setPrivacy] = useState('');
|
|
11
|
+
if (!registration || registrationAuthority(registration.registrar) !== 'route53') return <p>Read live registration before changing registrar settings.</p>;
|
|
12
|
+
const changed = autoRenew !== registration.auto_renew || privacy !== '';
|
|
13
|
+
return <form className="modal-pad domains-registrar-settings" onSubmit={event => { event.preventDefault(); if (!busy && !locked && changed) submit?.({ ...(autoRenew !== null && autoRenew !== registration.auto_renew ? { auto_renew: autoRenew } : {}), ...(privacy !== '' ? { privacy: privacy === 'true' } : {}) }); }}>
|
|
14
|
+
<h2 className="modal-title">Registration settings</h2><p className="dim">{domain.name}</p>
|
|
15
|
+
<div className="form-grid"><label className="field col-12"><span className="field-label">Auto-renew</span><select className="input" value={autoRenew === null ? '' : String(autoRenew)} disabled={busy || locked || registration.auto_renew === null} onChange={event => setAutoRenew(event.target.value === 'true')}><option value="" disabled>Not reported</option><option value="true">On</option><option value="false">Off</option></select></label>
|
|
16
|
+
<label className="field col-12"><span className="field-label">WHOIS privacy · all contacts</span><select className="input" value={privacy} disabled={busy || locked} onChange={event => setPrivacy(event.target.value)}><option value="">Keep current privacy</option><option value="true" disabled={registration.privacy_supported !== true}>Enable for all contacts</option><option value="false">Disable for all contacts</option></select></label></div>
|
|
17
|
+
{registration.privacy_supported !== true && <p className="dim">This registry has not confirmed support for enabling privacy.</p>}
|
|
18
|
+
{error && <p role="alert" className="text-bad">{error}</p>}{notice && <p role="status">{notice}</p>}
|
|
19
|
+
{(error || notice) && <a className="btn" href="https://console.aws.amazon.com/route53/domains/home" target="_blank" rel="noopener noreferrer">Open Route 53 registered domains</a>}
|
|
20
|
+
<div className="modal-actions">{close && <button type="button" className="btn" disabled={busy} onClick={close}>Close</button>}<button type="submit" className="btn btn-primary" disabled={busy || locked || !changed}>Save registration settings</button></div>
|
|
46
21
|
</form>;
|
|
47
22
|
}
|
|
48
|
-
|
|
49
|
-
export function showDomainRegistrarSettings(domain: DomainRow): Promise<unknown> {
|
|
23
|
+
/** Compatibility entry point now requires the same explicit live inspection. */
|
|
24
|
+
export async function showDomainRegistrarSettings(domain: DomainRow): Promise<unknown> {
|
|
25
|
+
const { DomainRegistrationPanel } = await import('./DomainRegistrationPanel');
|
|
50
26
|
let busy = false;
|
|
51
|
-
|
|
27
|
+
function Dialog({ close }: { close: () => void }) {
|
|
28
|
+
const [pending, setPending] = useState(false);
|
|
29
|
+
return <div className="modal-pad"><h2 className="modal-title">Registration · {domain.name}</h2><DomainRegistrationPanel domain={domain} onBusyChange={value => { busy = value; setPending(value); }}/><div className="modal-actions"><button className="btn" disabled={pending} onClick={() => { if (!busy) close(); }}>Close</button></div></div>;
|
|
30
|
+
}
|
|
31
|
+
return modal.open(close => <Dialog close={() => close(null)}/>, { size: 'md', canDismiss: () => !busy });
|
|
52
32
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
import { getAuthSessionRevision, getAuthSnapshot, subscribeAuth, useAuthSnapshot, useMe } from '../../client/runtime';
|
|
3
|
+
import { modal } from '../../ui/modal';
|
|
4
|
+
import { Tabs } from '../../ui/Tabs';
|
|
5
|
+
import { datetime } from '../../ui/format';
|
|
6
|
+
import { backendGlobalCan } from '../fleet/access';
|
|
7
|
+
import { FleetHelp } from '../fleet/FleetHelp';
|
|
8
|
+
import { DomainRegistrarSettings, type RegistrarChanges } from './DomainRegistrarSettings';
|
|
9
|
+
import { fetchDomainOverview, fetchDomainRegistration, registrationAuthority, registrationContact, saveDomainRegistrarSettings, saveRegistrationContacts, saveRegistrationPrivacy, type DomainRegistration, type RegistrationRequest } from './registrar-api';
|
|
10
|
+
import { CONTACT_STRING_FIELDS, validateContactDraft } from './registrant-data';
|
|
11
|
+
import type { DomainRow, RegistrantContact } from './models';
|
|
12
|
+
|
|
13
|
+
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' };
|
|
14
|
+
const CONTACT_FIELDS = ['FirstName', 'LastName', 'ContactType', 'OrganizationName', 'PhoneNumber', 'Email', 'Fax'] as const;
|
|
15
|
+
const ADDRESS_FIELDS = ['AddressLine1', 'AddressLine2', 'City', 'State', 'CountryCode', 'ZipCode'] as const;
|
|
16
|
+
const groupId = (domain: DomainRow) => typeof domain.group === 'number' ? domain.group : domain.group?.id ?? null;
|
|
17
|
+
const enabled = (value: boolean | null) => value === null ? 'Not reported' : value ? 'On' : 'Off';
|
|
18
|
+
const privacySnapshot = (row: DomainRegistration) => JSON.stringify([row.admin_privacy, row.registrant_privacy, row.tech_privacy]);
|
|
19
|
+
const privacy = (row: DomainRegistration) => {
|
|
20
|
+
const values = [row.admin_privacy, row.registrant_privacy, row.tech_privacy];
|
|
21
|
+
return values.every(value => value === true) ? 'On for all contacts' : values.every(value => value === false) ? 'Off for all contacts' : values.some(value => value === null) ? 'Not fully reported' : 'Mixed by contact';
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Live registrar evidence and contact PII stay in this mounted task, outside Query. */
|
|
25
|
+
export function DomainRegistrationPanel({ domain, onBusyChange, onChanged }: { domain: DomainRow; onBusyChange?: (busy: boolean) => void; onChanged?: () => void }) {
|
|
26
|
+
const auth = useAuthSnapshot(), { data: me } = useMe();
|
|
27
|
+
const revision = useSyncExternalStore(subscribeAuth, getAuthSessionRevision, getAuthSessionRevision);
|
|
28
|
+
const canManage = auth.authenticated && me?.is_active !== false && backendGlobalCan(me, ['manage_dns', 'security']);
|
|
29
|
+
const identity = JSON.stringify([domain.id, domain.name, domain.provider, groupId(domain), auth.uid, revision, canManage]);
|
|
30
|
+
const latest = useRef(identity); latest.current = identity;
|
|
31
|
+
const permitted = useRef(canManage); permitted.current = canManage;
|
|
32
|
+
const callbacks = useRef({ onBusyChange, onChanged }); callbacks.current = { onBusyChange, onChanged };
|
|
33
|
+
const controller = useRef<AbortController | null>(null), flight = useRef(false);
|
|
34
|
+
const [stored, setStored] = useState<{ identity: string; value: DomainRegistration } | null>(null);
|
|
35
|
+
const [busy, setBusy] = useState(false), [error, setError] = useState('');
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const owner = new AbortController(); controller.current = owner; setStored(null); setError(''); setBusy(false); flight.current = false;
|
|
38
|
+
return () => { owner.abort(); callbacks.current.onBusyChange?.(false); };
|
|
39
|
+
}, [identity]);
|
|
40
|
+
const registration = stored?.identity === identity ? stored.value : null;
|
|
41
|
+
const request = (): RegistrationRequest => {
|
|
42
|
+
const signal = controller.current?.signal, selected = identity, uid = auth.uid, rev = revision;
|
|
43
|
+
const beforeSend = () => { if (!signal || signal.aborted || latest.current !== selected || !permitted.current || !getAuthSnapshot().authenticated || getAuthSnapshot().uid !== uid || getAuthSessionRevision() !== rev) throw new DOMException('Registration view changed', 'AbortError'); };
|
|
44
|
+
beforeSend(); return { signal, beforeSend };
|
|
45
|
+
};
|
|
46
|
+
const accept = (value: DomainRegistration, options: RegistrationRequest) => { options.beforeSend?.(); setStored({ identity, value }); };
|
|
47
|
+
const read = async () => {
|
|
48
|
+
if (flight.current) return;
|
|
49
|
+
let options: RegistrationRequest; try { options = request(); } catch { return; }
|
|
50
|
+
flight.current = true; setBusy(true); setError(''); callbacks.current.onBusyChange?.(true);
|
|
51
|
+
try { accept(await fetchDomainRegistration(domain, options), options); }
|
|
52
|
+
catch { if (!options.signal?.aborted && latest.current === identity) { setStored(null); setError('Registration could not be read. The DNS provider does not establish where this domain is registered. Check the registrar account, then try again.'); } }
|
|
53
|
+
finally { if (!options.signal?.aborted && latest.current === identity) { flight.current = false; setBusy(false); callbacks.current.onBusyChange?.(false); } }
|
|
54
|
+
};
|
|
55
|
+
const openEditor = async (mode: 'settings' | 'contacts') => {
|
|
56
|
+
if (!registration || registrationAuthority(registration.registrar) !== 'route53') return;
|
|
57
|
+
let options: RegistrationRequest; try { options = request(); } catch { return; }
|
|
58
|
+
let pending = false;
|
|
59
|
+
await modal.open(close => <RegistrationEditor domain={domain} registration={registration} mode={mode} request={request} onEvidence={value => accept(value, options)} onChanged={() => callbacks.current.onChanged?.()} onBusy={value => { pending = value; callbacks.current.onBusyChange?.(value); }} close={() => close(null)}/>, { signal: options.signal, size: mode === 'contacts' ? 'lg' : 'md', canDismiss: () => !pending });
|
|
60
|
+
};
|
|
61
|
+
if (!canManage) return <p className="dim">DNS management authority is required to read registration details.</p>;
|
|
62
|
+
if (domain.provider === 'godaddy') return <section className="domains-registration"><p>Registration, renewal and contact details stay in the GoDaddy account that holds this domain.</p><a className="btn" href="https://dcc.godaddy.com/domains" target="_blank" rel="noopener noreferrer">Open GoDaddy domains</a></section>;
|
|
63
|
+
if (domain.provider !== 'route53') return <p className="dim">Registration is managed by this domain’s registrar.</p>;
|
|
64
|
+
return <section className="domains-registration"><div className="domains-overview-actions"><button className="btn" disabled={busy} onClick={() => void read()}>{busy ? 'Reading registration…' : registration ? 'Refresh registration' : 'Read registration'}</button><FleetHelp label="registration details">Reads live registrar data, including private contact details, for this domain only. DNS hosting and domain registration can use different providers.</FleetHelp></div>
|
|
65
|
+
{error && <p className="text-bad" role="alert">{error}</p>}
|
|
66
|
+
{registration && <><dl className="detail-grid"><div><dt>Registrar</dt><dd>{registration.registrar || 'Not reported'}</dd></div><div><dt>Expires</dt><dd>{registration.expires ? datetime(registration.expires) : 'Not reported'}</dd></div><div><dt>Auto-renew</dt><dd>{enabled(registration.auto_renew)}</dd></div><div><dt>WHOIS privacy</dt><dd>{privacy(registration)}</dd></div></dl>
|
|
67
|
+
{registrationAuthority(registration.registrar) === 'route53' ? <div className="domains-overview-actions"><button className="btn" disabled={busy} onClick={() => void openEditor('settings')}>Registration settings…</button><button className="btn" disabled={busy} onClick={() => void openEditor('contacts')}>Contacts…</button></div> : <p className="dim">{registrationAuthority(registration.registrar) === 'godaddy' ? 'Manage registration in the GoDaddy account that holds this domain.' : 'Local registration edits are unavailable because this registrar has not been confirmed as Route 53.'}</p>}
|
|
68
|
+
</>}
|
|
69
|
+
</section>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function RegistrationEditor({ domain, registration, mode, request, onEvidence, onChanged, onBusy, close }: { domain: DomainRow; registration: DomainRegistration; mode: 'settings' | 'contacts'; request: () => RegistrationRequest; onEvidence: (value: DomainRegistration) => void; onChanged: () => void; onBusy: (value: boolean) => void; close: () => void }) {
|
|
73
|
+
const [busy, setBusy] = useState(false), [locked, setLocked] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState('');
|
|
74
|
+
const [editing, setEditing] = useState(false), [tab, setTab] = useState('contact');
|
|
75
|
+
const [draft, setDraft] = useState<RegistrantContact>(() => registrationContact(registration.registrant));
|
|
76
|
+
const flight = useRef(false), alive = useRef(true);
|
|
77
|
+
const callback = useRef(onBusy); callback.current = onBusy;
|
|
78
|
+
useEffect(() => { alive.current = true; return () => { alive.current = false; callback.current(false); }; }, []);
|
|
79
|
+
const save = async (changes: RegistrarChanges | RegistrantContact, contact: boolean) => {
|
|
80
|
+
if (flight.current || locked) return;
|
|
81
|
+
if (contact) {
|
|
82
|
+
const problems = validateContactDraft(changes as RegistrantContact);
|
|
83
|
+
if (problems.length) { setError('Complete the required Contact and Address fields. Use +country.number for phone numbers.'); setTab(problems.some(problem => ADDRESS_FIELDS.some(field => problem.startsWith(field))) ? 'address' : 'contact'); return; }
|
|
84
|
+
}
|
|
85
|
+
let options: RegistrationRequest; try { options = request(); } catch { return; }
|
|
86
|
+
flight.current = true; setBusy(true); setError(''); onBusy(true);
|
|
87
|
+
let submitted = false, readbackAttempted = false;
|
|
88
|
+
try {
|
|
89
|
+
const object = await fetchDomainOverview(domain.id, options);
|
|
90
|
+
if (object.name !== domain.name || object.provider !== domain.provider || groupId(object) !== groupId(domain)) throw new Error('Domain ownership changed');
|
|
91
|
+
const fresh = await fetchDomainRegistration(domain, options);
|
|
92
|
+
if (registrationAuthority(fresh.registrar) !== 'route53') throw new Error('Registrar changed');
|
|
93
|
+
if (contact ? JSON.stringify([fresh.registrant, fresh.admin, fresh.tech]) !== JSON.stringify([registration.registrant, registration.admin, registration.tech]) : fresh.auto_renew !== registration.auto_renew || privacySnapshot(fresh) !== privacySnapshot(registration) || fresh.privacy_supported !== registration.privacy_supported) throw new Error('Registration changed');
|
|
94
|
+
if (!contact && (changes as RegistrarChanges).privacy === true && fresh.privacy_supported !== true) throw new Error('Privacy not supported');
|
|
95
|
+
setLocked(true); submitted = true;
|
|
96
|
+
if (contact) await saveRegistrationContacts(domain, changes as RegistrantContact, options);
|
|
97
|
+
else {
|
|
98
|
+
const settings = changes as RegistrarChanges;
|
|
99
|
+
if (settings.auto_renew !== undefined) await saveDomainRegistrarSettings(domain.id, { auto_renew: settings.auto_renew }, options);
|
|
100
|
+
if (settings.privacy !== undefined) await saveRegistrationPrivacy(domain, settings.privacy, options);
|
|
101
|
+
}
|
|
102
|
+
readbackAttempted = true;
|
|
103
|
+
const observed = await fetchDomainRegistration(domain, options); onEvidence(observed);
|
|
104
|
+
const matched = contact ? Object.entries(changes).every(([key, value]) => JSON.stringify(observed.registrant[key as keyof RegistrantContact]) === JSON.stringify(value)) && [observed.admin, observed.tech].every(role => Object.entries(changes).every(([key, value]) => JSON.stringify(role[key as keyof RegistrantContact]) === JSON.stringify(value))) : ((changes as RegistrarChanges).auto_renew === undefined || observed.auto_renew === (changes as RegistrarChanges).auto_renew) && ((changes as RegistrarChanges).privacy === undefined || [observed.admin_privacy, observed.registrant_privacy, observed.tech_privacy].every(value => value === (changes as RegistrarChanges).privacy));
|
|
105
|
+
setNotice(matched ? 'Requested values are now visible in the live registrar read.' : 'Request accepted. The registrar has not yet reported all requested values. Refresh registration to check progress.');
|
|
106
|
+
} catch {
|
|
107
|
+
if (!options.signal?.aborted && alive.current) setError(submitted ? 'The registrar outcome is unconfirmed. No request will be repeated. Close and refresh registration before another decision.' : 'Registration or domain ownership changed, or could not be checked. Close and refresh registration before editing.');
|
|
108
|
+
} finally {
|
|
109
|
+
if (submitted && !readbackAttempted && !options.signal?.aborted) {
|
|
110
|
+
try { onEvidence(await fetchDomainRegistration(domain, options)); } catch { /* Unknown outcome remains locked; never replay. */ }
|
|
111
|
+
}
|
|
112
|
+
if (!options.signal?.aborted && alive.current) { if (submitted) onChanged(); flight.current = false; setBusy(false); onBusy(false); }
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
if (mode === 'settings') return <DomainRegistrarSettings domain={domain} registration={registration} busy={busy} locked={locked} error={error} notice={notice} submit={changes => void save(changes, false)} close={close}/>;
|
|
116
|
+
const fields = (keys: readonly (typeof CONTACT_STRING_FIELDS)[number][]) => <div className="form-grid">{keys.map(key => <label className={`field ${['OrganizationName', 'AddressLine1', 'AddressLine2'].includes(key) ? 'col-12' : 'col-6'}`} key={key}><span className="field-label">{LABELS[key]}</span>{key === 'ContactType' ? <select className="input" value={draft[key] ?? ''} disabled={busy || locked} onChange={event => setDraft(value => ({ ...value, [key]: event.target.value }))}><option value="">Choose…</option>{!['PERSON', 'COMPANY', 'ASSOCIATION', 'PUBLIC_BODY', 'RESELLER'].includes(draft.ContactType ?? '') && draft.ContactType && <option value={draft.ContactType} disabled>Unknown contact type</option>}{['PERSON', 'COMPANY', 'ASSOCIATION', 'PUBLIC_BODY', 'RESELLER'].map(value => <option key={value}>{value}</option>)}</select> : <input className="input" type={key === 'Email' ? 'email' : 'text'} maxLength={500} value={draft[key] ?? ''} disabled={busy || locked} onChange={event => setDraft(value => ({ ...value, [key]: event.target.value }))}/>}</label>)}</div>;
|
|
117
|
+
return <form className="modal-pad domains-registration-contact" onSubmit={event => { event.preventDefault(); void save(registrationContact(draft), true); }}><h2 className="modal-title">Registration contacts</h2><p className="dim">{domain.name}</p>
|
|
118
|
+
{editing ? <><p>Replace Registrant, Admin and Technical contacts with these details. A changed email may require verification.</p><Tabs ariaLabel="Contact fields" activeKey={tab} onActiveKeyChange={key => { if (!flight.current) setTab(key); }} items={[{ key: 'contact', label: 'Contact', panel: fields(CONTACT_FIELDS) }, { key: 'address', label: 'Address', panel: fields(ADDRESS_FIELDS) }]}/></> : JSON.stringify(registration.registrant) === JSON.stringify(registration.admin) && JSON.stringify(registration.admin) === JSON.stringify(registration.tech) ? <><p className="dim">Same contact for Registrant, Admin and Technical.</p><ContactFacts contact={registration.registrant}/></> : <Tabs ariaLabel="Registrar contact roles" items={(['registrant', 'admin', 'tech'] as const).map(role => ({ key: role, label: role === 'tech' ? 'Technical' : role === 'admin' ? 'Admin' : 'Registrant', panel: <ContactFacts contact={registration[role]}/> }))}/>}
|
|
119
|
+
{error && <p role="alert" className="text-bad">{error}</p>}{notice && <p role="status">{notice}</p>}
|
|
120
|
+
<div className="modal-actions"><button type="button" className="btn" disabled={busy} onClick={close}>Close</button>{editing ? <button key="save" className="btn btn-primary" type="submit" disabled={busy || locked}>Replace all contacts</button> : <button key="edit" className="btn btn-primary" type="button" onClick={event => { event.preventDefault(); setEditing(true); }}>Edit all contacts…</button>}</div>
|
|
121
|
+
</form>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ContactFacts({ contact }: { contact: RegistrantContact }) {
|
|
125
|
+
const address = [contact.AddressLine1, contact.AddressLine2, [contact.City, contact.State, contact.ZipCode].filter(Boolean).join(', '), contact.CountryCode].filter(Boolean);
|
|
126
|
+
return <dl className="detail-grid"><div><dt>Contact</dt><dd><strong>{[contact.FirstName, contact.LastName].filter(Boolean).join(' ') || 'Not reported'}</strong>{contact.OrganizationName && <div>{contact.OrganizationName}</div>}{contact.ContactType && <small className="dim">{contact.ContactType}</small>}</dd></div><div><dt>Email / phone</dt><dd>{contact.Email && <div>{contact.Email}</div>}{contact.PhoneNumber && <div>{contact.PhoneNumber}</div>}{contact.Fax && <div>Fax: {contact.Fax}</div>}{!contact.Email && !contact.PhoneNumber && !contact.Fax && 'Not reported'}</dd></div><div><dt>Address</dt><dd>{address.length ? address.map((line, index) => <div key={index}>{line}</div>) : 'Not reported'}</dd></div></dl>;
|
|
127
|
+
}
|
|
@@ -1,40 +1,41 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useRef, useState, useSyncExternalStore } from 'react';
|
|
2
2
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
3
|
-
import { Badge, ModelTable,
|
|
4
|
-
import {
|
|
3
|
+
import { Badge, ModelTable, Popover, fmt, type Column, type FilterDef } from '../../ui';
|
|
4
|
+
import { showDomainDetails } from './DomainDetail';
|
|
5
5
|
import { hasPermission, useMe } from '../../client/runtime';
|
|
6
6
|
import { DomainModel, DNS_MANAGE_PERMISSIONS, type DomainRow } from './models';
|
|
7
7
|
import { providerLabel } from './data';
|
|
8
|
-
import {
|
|
8
|
+
import { useAdminRouteContext } from '../core/route-context';
|
|
9
|
+
import { domainRecordsHref, domainScopeLabel, dnsAdminMount, supportsDomainRecords } from './domain-navigation';
|
|
9
10
|
|
|
10
11
|
const filters: FilterDef[] = [
|
|
11
12
|
{ key: 'provider', label: 'Provider', type: 'select', options: [{ value: 'route53', label: 'Route 53' }, { value: 'godaddy', label: 'GoDaddy' }, { value: 'mojo', label: 'Mojo certificate-only' }] },
|
|
12
|
-
{ key: 'status', label: 'Status', type: 'select', options: ['pending', 'registering', 'active'].map(
|
|
13
|
+
{ key: 'status', label: 'Status', type: 'select', options: ['pending', 'registering', 'active'].map(value => ({ value, label: value })) },
|
|
13
14
|
];
|
|
15
|
+
const narrowQuery = () => window.matchMedia('(max-width: 560px)');
|
|
16
|
+
const subscribeNarrow = (listener: () => void) => { const media = narrowQuery(); media.addEventListener('change', listener); return () => media.removeEventListener('change', listener); };
|
|
14
17
|
|
|
15
18
|
export function DomainsPage() {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
+
const { data: me } = useMe(); const location = useLocation(); const navigate = useNavigate();
|
|
20
|
+
const [menu, setMenu] = useState(false); const anchor = useRef<HTMLButtonElement>(null);
|
|
21
|
+
const narrow = useSyncExternalStore(subscribeNarrow, () => narrowQuery().matches, () => false);
|
|
19
22
|
const canManage = hasPermission(me ?? null, DNS_MANAGE_PERMISSIONS, null);
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
};
|
|
23
|
+
const route = useAdminRouteContext();
|
|
24
|
+
const related = [
|
|
25
|
+
{ path: 'records', label: 'DNS records' },
|
|
26
|
+
{ path: 'credentials', label: 'Provider credentials' },
|
|
27
|
+
...(canManage ? [{ path: 'registrant', label: 'Registrant contact' }] : []),
|
|
28
|
+
].filter(item => !route || route.paths.includes(`${dnsAdminMount(location.pathname)}/dns/${item.path}`));
|
|
29
|
+
const start = async () => { const { showDomainOnboarding } = await import('./DomainOnboardingDialog'); await showDomainOnboarding(); };
|
|
28
30
|
const columns: Column<DomainRow>[] = [
|
|
29
|
-
{ key: 'name', label: 'Domain', sortable: true, hideable: false, render:
|
|
30
|
-
{ key: 'status', label: 'Status', sortable: true, render:
|
|
31
|
-
{ key: 'expires', label: 'Expires', sortable: true, render:
|
|
32
|
-
{ key: 'group', label: 'Scope', render:
|
|
33
|
-
{ key: 'records', label: 'DNS
|
|
31
|
+
{ key: 'name', label: 'Domain', sortable: true, hideable: false, render: row => <span className="ops-cell"><strong>{row.name}</strong><small>{providerLabel(row.provider)}<span className="domains-narrow-scope"> · {domainScopeLabel(row.group)}</span></small><span className="domains-narrow-status"><Badge tone={fmt.inferTone(row.status)}>{row.status}</Badge></span></span> },
|
|
32
|
+
{ key: 'status', label: 'Status', sortable: true, render: row => <Badge tone={fmt.inferTone(row.status)}>{row.status}</Badge> },
|
|
33
|
+
{ key: 'expires', label: 'Expires', sortable: true, render: row => row.expires ? fmt.date(row.expires) : 'Unknown' },
|
|
34
|
+
{ key: 'group', label: 'Scope', sortable: false, render: row => <span className="domains-scope">{domainScopeLabel(row.group)}</span> },
|
|
35
|
+
{ key: 'records', label: 'DNS records', sortable: false, render: row => supportsDomainRecords(row.provider) ? <button className="btn btn-sm" type="button" onClick={event => { event.stopPropagation(); navigate(domainRecordsHref(location.pathname, row.id)); }}>Records <i className="bi bi-arrow-right" /></button> : <span className="dim">{row.provider === 'mojo' ? 'Certificate-only' : 'Unavailable'}</span> },
|
|
34
36
|
];
|
|
35
37
|
return <div className="domains-page">
|
|
36
|
-
<header className="domains-header"><div><div className="eyebrow">Network & hosting</div><h1>Domains</h1><p className="dim">Manage
|
|
37
|
-
<ModelTable<DomainRow> model={DomainModel} title="Managed domains" searchable searchPlaceholder="Search domain names" columns={columns} filters={filters} defaultParams={{ sort: 'name' }} columnChooser persistState persistKey="admin:dns:domains" onRowClick={
|
|
38
|
-
{canManage && <details className="domains-advanced"><summary>Advanced</summary><p className="dim">Connect external domains to shared wildcard validation, or review controller allocations and trust.</p><button className="btn" onClick={() => void import('./SharedValidationDialog').then(({ openSharedValidation }) => openSharedValidation())}>Shared validation setup</button></details>}
|
|
38
|
+
<header className="domains-header"><div><div className="eyebrow">Network & hosting</div><h1>Domains</h1><p className="dim">Manage your domains and DNS.</p></div><div className="domains-actions"><button className="btn" onClick={() => navigate(`${dnsAdminMount(location.pathname)}/dns/purchases`)}>Purchases</button>{canManage && <button className="btn btn-primary" onClick={() => void start()}><i className="bi bi-plus-lg" /> Add domain</button>}{(related.length > 0 || canManage) && <><button ref={anchor} className="btn-icon" aria-label="More domain actions" aria-expanded={menu} onClick={() => setMenu(value => !value)}><i className="bi bi-three-dots-vertical" /></button><Popover anchorRef={anchor} open={menu} onClose={() => setMenu(false)} className="domains-action-menu">{related.map(item => <button key={item.path} className="btn" onClick={() => { setMenu(false); navigate(`${dnsAdminMount(location.pathname)}/dns/${item.path}`); }}>{item.label}</button>)}{canManage && <button className="btn" onClick={() => { setMenu(false); void import('./SharedValidationDialog').then(({ openSharedValidation }) => openSharedValidation()); }}>Shared validation setup</button>}</Popover></>}</div></header>
|
|
39
|
+
<ModelTable<DomainRow> model={DomainModel} title="Managed domains" searchable searchPlaceholder="Search domain names" columns={narrow ? columns.filter(column => !['expires', 'group', 'status'].includes(column.key)) : columns} filters={filters} defaultParams={{ sort: 'name' }} columnChooser persistState persistKey="admin:dns:domains" onRowClick={row => void showDomainDetails(row.id)} />
|
|
39
40
|
</div>;
|
|
40
41
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useQueryClient } from '@tanstack/react-query';
|
|
2
|
-
import { useRef, useState } from 'react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { useCan } from '../../client/runtime';
|
|
4
4
|
import {
|
|
5
5
|
Badge,
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
useDnsCapabilities,
|
|
23
23
|
} from './api';
|
|
24
24
|
import { linkableProviders, providerLabel } from './data';
|
|
25
|
+
import { domainSession, requireDomainManager } from './domain-authority';
|
|
25
26
|
import {
|
|
26
27
|
DNS_MANAGE_PERMISSIONS,
|
|
27
28
|
DNS_VIEW_PERMISSIONS,
|
|
@@ -45,10 +46,13 @@ function verificationTone(row: DnsCredentialRow): Tone {
|
|
|
45
46
|
return row.verified ? 'success' : row.last_error ? 'danger' : 'warning';
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
function CredentialSecretForm({ row, caps, close }: {
|
|
49
|
+
function CredentialSecretForm({ row, caps, close, fixedGroup, onBusy, signal }: {
|
|
49
50
|
row?: DnsCredentialRow;
|
|
50
51
|
caps: DnsCapabilities;
|
|
51
52
|
close: (saved: DnsCredentialRow | null) => void;
|
|
53
|
+
fixedGroup?: DnsGroupChoice;
|
|
54
|
+
onBusy?: (busy: boolean) => void;
|
|
55
|
+
signal?: AbortSignal;
|
|
52
56
|
}) {
|
|
53
57
|
const queryClient = useQueryClient();
|
|
54
58
|
const providers = linkableProviders(caps);
|
|
@@ -56,37 +60,45 @@ function CredentialSecretForm({ row, caps, close }: {
|
|
|
56
60
|
const secretRef = useRef<HTMLInputElement>(null);
|
|
57
61
|
const [name, setName] = useState(row?.name ?? '');
|
|
58
62
|
const [provider, setProvider] = useState(row?.provider ?? providers[0]?.name ?? '');
|
|
59
|
-
const [group, setGroup] = useState<DnsGroupChoice | null>(null);
|
|
63
|
+
const [group, setGroup] = useState<DnsGroupChoice | null>(fixedGroup ?? null);
|
|
60
64
|
const [busy, setBusy] = useState(false);
|
|
61
65
|
const [error, setError] = useState('');
|
|
62
|
-
const
|
|
66
|
+
const scopeLabel = row ? groupLabel(row.group) : fixedGroup?.name;
|
|
67
|
+
const flight = useRef(false); const session = useRef(domainSession());
|
|
68
|
+
const mounted = useRef(true);
|
|
69
|
+
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
63
70
|
|
|
64
71
|
const submit = async (event: React.FormEvent) => {
|
|
65
72
|
event.preventDefault();
|
|
73
|
+
if (flight.current) return;
|
|
66
74
|
const apiKey = keyRef.current?.value ?? '';
|
|
67
75
|
const apiSecret = secretRef.current?.value ?? '';
|
|
68
76
|
if (!apiKey || !apiSecret) { setError('Enter both the API key and API secret'); return; }
|
|
69
77
|
if (!row && !group) { setError('Choose a group'); return; }
|
|
70
78
|
if (!provider) { setError('Choose a provider'); return; }
|
|
71
|
-
setBusy(true);
|
|
79
|
+
flight.current = true; setBusy(true); onBusy?.(true);
|
|
72
80
|
setError('');
|
|
73
81
|
try {
|
|
82
|
+
await requireDomainManager(session.current);
|
|
83
|
+
const beforeSend = () => { if (!mounted.current || signal?.aborted || domainSession() !== session.current) throw new Error('Session changed. Reopen credential setup.'); };
|
|
74
84
|
const saved = row
|
|
75
|
-
? await rotateDnsCredential(row, { name, api_key: apiKey, api_secret: apiSecret })
|
|
85
|
+
? await rotateDnsCredential(row, { name, api_key: apiKey, api_secret: apiSecret }, beforeSend)
|
|
76
86
|
: await linkDnsCredential({
|
|
77
87
|
group: group!.id, provider, name,
|
|
78
88
|
api_key: apiKey, api_secret: apiSecret,
|
|
79
|
-
});
|
|
89
|
+
}, beforeSend);
|
|
90
|
+
if (!mounted.current || signal?.aborted || domainSession() !== session.current) return;
|
|
80
91
|
queryClient.setQueryData(DnsCredentialModel.keys.one(saved.id), saved);
|
|
81
92
|
toast.success(row ? 'Credential rotated and verified' : 'Credential linked and verified');
|
|
82
93
|
close(saved);
|
|
83
94
|
} catch (reason) {
|
|
84
|
-
setError(reason instanceof Error ? reason.message : 'The provider rejected those credentials');
|
|
95
|
+
if (mounted.current && !signal?.aborted) setError(reason instanceof Error ? reason.message : 'The provider rejected those credentials');
|
|
85
96
|
} finally {
|
|
86
97
|
if (keyRef.current) keyRef.current.value = '';
|
|
87
98
|
if (secretRef.current) secretRef.current.value = '';
|
|
88
99
|
await invalidateDnsCredentials(queryClient);
|
|
89
100
|
setBusy(false);
|
|
101
|
+
flight.current = false; onBusy?.(false);
|
|
90
102
|
}
|
|
91
103
|
};
|
|
92
104
|
|
|
@@ -101,15 +113,15 @@ function CredentialSecretForm({ row, caps, close }: {
|
|
|
101
113
|
<div className="col-12">
|
|
102
114
|
<label className="field">
|
|
103
115
|
<span className="field-label">Label</span>
|
|
104
|
-
<input className="input" value={name} placeholder="Acme GoDaddy" onChange={(event) => setName(event.target.value)} />
|
|
116
|
+
<input className="input" value={name} disabled={busy} placeholder="Acme GoDaddy" onChange={(event) => setName(event.target.value)} />
|
|
105
117
|
</label>
|
|
106
118
|
</div>
|
|
107
119
|
<div className="col-12">
|
|
108
|
-
{row ? (
|
|
120
|
+
{row || fixedGroup ? (
|
|
109
121
|
<div className="field">
|
|
110
122
|
<span className="field-label">Group</span>
|
|
111
|
-
<div className="input" aria-readonly="true">{
|
|
112
|
-
<span className="field-help">Rotation keeps the existing credential scope.</span>
|
|
123
|
+
<div className="input" aria-readonly="true">{scopeLabel}</div>
|
|
124
|
+
{row && <span className="field-help">Rotation keeps the existing credential scope.</span>}
|
|
113
125
|
</div>
|
|
114
126
|
) : (
|
|
115
127
|
<CollectionSelect<DnsGroupChoice>
|
|
@@ -120,6 +132,7 @@ function CredentialSecretForm({ row, caps, close }: {
|
|
|
120
132
|
placeholder="Search eligible groups…"
|
|
121
133
|
help="Only active groups authorized for DNS credential assignment appear."
|
|
122
134
|
required
|
|
135
|
+
disabled={busy}
|
|
123
136
|
maxItems={25}
|
|
124
137
|
defaultParams={{ start: 0 }}
|
|
125
138
|
/>
|
|
@@ -131,7 +144,7 @@ function CredentialSecretForm({ row, caps, close }: {
|
|
|
131
144
|
{row ? (
|
|
132
145
|
<div className="input" aria-readonly="true">{providerLabel(provider)}</div>
|
|
133
146
|
) : (
|
|
134
|
-
<select className="input" value={provider} onChange={(event) => setProvider(event.target.value)}>
|
|
147
|
+
<select className="input" value={provider} disabled={busy} onChange={(event) => setProvider(event.target.value)}>
|
|
135
148
|
{providers.map((entry) => <option key={entry.name} value={entry.name}>{providerLabel(entry.name)}</option>)}
|
|
136
149
|
</select>
|
|
137
150
|
)}
|
|
@@ -140,13 +153,13 @@ function CredentialSecretForm({ row, caps, close }: {
|
|
|
140
153
|
<div className="col-12">
|
|
141
154
|
<label className="field">
|
|
142
155
|
<span className="field-label">API key <em>*</em></span>
|
|
143
|
-
<input ref={keyRef} className="input" type="password" autoComplete="new-password" spellCheck={false} />
|
|
156
|
+
<input ref={keyRef} className="input" type="password" disabled={busy} autoComplete="new-password" spellCheck={false} />
|
|
144
157
|
</label>
|
|
145
158
|
</div>
|
|
146
159
|
<div className="col-12">
|
|
147
160
|
<label className="field">
|
|
148
161
|
<span className="field-label">API secret <em>*</em></span>
|
|
149
|
-
<input ref={secretRef} className="input" type="password" autoComplete="new-password" spellCheck={false} />
|
|
162
|
+
<input ref={secretRef} className="input" type="password" disabled={busy} autoComplete="new-password" spellCheck={false} />
|
|
150
163
|
</label>
|
|
151
164
|
</div>
|
|
152
165
|
</div>
|
|
@@ -160,10 +173,11 @@ function CredentialSecretForm({ row, caps, close }: {
|
|
|
160
173
|
);
|
|
161
174
|
}
|
|
162
175
|
|
|
163
|
-
async function showCredentialEditor(caps: DnsCapabilities, row?: DnsCredentialRow): Promise<DnsCredentialRow | null> {
|
|
176
|
+
export async function showCredentialEditor(caps: DnsCapabilities, row?: DnsCredentialRow, fixedGroup?: DnsGroupChoice, options: { signal?: AbortSignal; onBusyChange?: (busy: boolean) => void } = {}): Promise<DnsCredentialRow | null> {
|
|
177
|
+
let busy = false;
|
|
164
178
|
return modal.open<DnsCredentialRow | null>((close) => (
|
|
165
|
-
<CredentialSecretForm caps={caps} row={row} close={close} />
|
|
166
|
-
), { size: 'md' });
|
|
179
|
+
<CredentialSecretForm caps={caps} row={row} fixedGroup={fixedGroup} signal={options.signal} close={close} onBusy={value => { busy = value; options.onBusyChange?.(value); }} />
|
|
180
|
+
), { signal: options.signal, size: 'md', canDismiss: () => !busy });
|
|
167
181
|
}
|
|
168
182
|
|
|
169
183
|
function CredentialDetail({ id, caps, close }: { id: number; caps: DnsCapabilities; close: () => void }) {
|