Files
technitium-console/src/screens/zones/Zones.tsx
T
DenozordecandClaude Opus 5.5 8bb1e01936 Hub stage 5: manage each server from one console
Zones, Cache, Blocking, DNS Client, Settings, DHCP and Logs can now be
changed on one chosen server through the hub, with that server's admin
token. Operators manage cache and blocking; admins also zones, settings
and DHCP. Under "All servers", or on a server without an admin token,
the controls are disabled with a hint.

The hub refuses settings/set changes to a server's web service fields
and anything about users, SSO or LDAP; backup, restore, log and stats
deletion and admin/* stay refused. Non-admins get settings with secrets
blanked. Write params travel in the POST body, downloads pass through
with a single-use token, and the audit log names no values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:06:37 +07:00

367 lines
12 KiB
TypeScript

import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { useContext, useState } from 'react'
import { HubMode } from '../../ui/ClusterNodeSelect'
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
import { HubWriteHint } from '../../ui/HubWriteHint'
import type { ResourceRecord, ZoneDetails } from '../../api/records'
import { Confirm } from '../../ui/Confirm'
import { ZoneList } from './ZoneList'
import { ZoneRecords } from './ZoneRecords'
import { AddEditRecord } from './modals/AddEditRecord'
import { AddZone } from './modals/AddZone'
import { CloneZone } from './modals/CloneZone'
import { ConvertZone } from './modals/ConvertZone'
import { UnsignZone } from './modals/UnsignZone'
import { SignZone } from './modals/SignZone'
import { ImportZone } from './modals/ImportZone'
import { EditZoneFile } from './modals/EditZoneFile'
import { ZoneOptions } from './modals/ZoneOptions'
import { ZonePermissions } from './modals/ZonePermissions'
import { DnssecProperties } from './modals/DnssecProperties'
import { ViewDs } from './modals/ViewDs'
import type { Notice, Confirmation } from './types'
import { Notifier } from '../../ui/Notifier'
/*
The whole Zones section: the list, a zone's view and the ten modals.
In upstream they are two `div` shown and hidden (`divViewZones` and
`divEditZone`); here it is a state, `open`. The rest of the structure is the
same on purpose: same modals, same texts, same steps.
**The alerts live here, not in each screen.** In upstream a `showAlert` with no
container draws them in the global bar, so an action fired from a modal that
closes the modal leaves the alert visible behind it. With the alert inside each
screen that case would be lost.
*/
type ModalId =
| 'add'
| 'editZoneFile'
| 'import'
| 'clone'
| 'convert'
| 'options'
| 'permissions'
| 'sign'
| 'unsign'
| 'viewds'
| 'dnssec'
| 'record'
export interface ZonesProps {
/** The cluster nodes, for the node selector. */
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
token: string | null
node?: string
canModify: boolean
canDelete: boolean
/** They inherit from Settings' global setting for the add modal. */
useSoaSerialDateScheme?: boolean
dnssecValidation?: boolean
/** The cluster's domain, when it is initialised: Add Zone preselects its catalog. */
clusterDomain?: string
/** The server's default TTLs, the record form's placeholders. */
recordTtls?: { defaultRecordTtl: number; defaultNsRecordTtl: number; defaultSoaRecordTtl: number }
}
const DEFAULT_TTLS = { defaultRecordTtl: 3600, defaultNsRecordTtl: 14400, defaultSoaRecordTtl: 900 }
export function Zones({
token,
nodes = [],
clusterInitialised = false,
canModify: mayModify,
canDelete: mayDelete,
useSoaSerialDateScheme = false,
dnssecValidation = false,
recordTtls = DEFAULT_TTLS,
clusterDomain,
}: ZonesProps) {
/* Upstream mounts a selector here (`optZonesClusterNode`) with no aggregate
and no persistence; the parameter was already travelling. */
const hub = useContext(HubMode)
/* Behind a hub the empty node is the hub, not a server: the first one is chosen, and a change
is allowed only on a server the hub can write to. */
const defaultNode = useHubDefaultNode(nodes)
const [node, setNode] = useState<string>(defaultNode)
const write = useHubWrite(node, mayModify)
const canModify = write.allowed
const canDelete = useHubWrite(node, mayDelete).allowed
const [open, setOpen] = useState<string | null>(null)
const [notice, setNotice] = useState<Notice | null>(null)
const [confirmation, setConfirmation] = useState<Confirmation | null>(null)
const [modal, setModal] = useState<ModalId | null>(null)
/** The zone the open modal acts on; it may not be the one on screen. */
const [modalZone, setModalZone] = useState('')
const [modalKind, setModalKind] = useState('')
const [recordMode, setRecordMode] = useState<'add' | 'update'>('add')
const [zoneDetails, setZoneDetails] = useState<ZoneDetails | null>(null)
const [records, setRecords] = useState<ResourceRecord[]>([])
const [originalRecord, setOriginalRecord] = useState<ResourceRecord | null>(null)
/*
The expiry TTL left over in the record modal. Upstream reads it from that
field when DISABLING a record from the row (`updateRecordState`,
zone.js:6236), so the value survives the modal's closing. It is a bug of
theirs; it is replicated because the rule is zero behaviour changes.
*/
const [modalExpiryTtl, setModalExpiryTtl] = useState('')
const [listRefresh, setListRefresh] = useState(0)
const [zoneRefresh, setZoneRefresh] = useState(0)
const refreshList = () => setListRefresh((n) => n + 1)
const refreshZone = () => setZoneRefresh((n) => n + 1)
function openModal(id: ModalId, zone: string, type = '') {
setModalZone(zone)
setModalKind(type)
setModal(id)
}
/** After a mutating modal: refreshes whatever is in front. */
function done(a: Notice) {
setNotice(a)
if (open == null) refreshList()
else refreshZone()
}
/*
After "Edit Zone File" and "Import Zone": upstream reloads the zone ONLY if its view is the one
showing (`if ($("#divEditZone").is(":visible")) showEditZone(zone)`,
zone.js:1302-1303 in v15.5.1). From the list nothing is reloaded, so this is
not `done`.
*/
function zoneFileSaved(a: Notice) {
setNotice(a)
if (open != null) refreshZone()
}
return (
<>
<Notifier notice={notice} onClose={() => setNotice(null)} />
{/* Draws nothing without a cluster. Upstream has one for the list and a
second for the records view (`optEditZoneClusterNode`); both read the
same state here, since it is the same question. The list's reloads
the list (index.html:436); the records view's is always disabled
(index.html:548). */}
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
value={node}
onChange={(n) => {
setNode(n)
refreshList()
}}
disabled={open != null}
label="Cluster Node"
/>
<HubWriteHint write={write} />
{open == null ? (
<ZoneList
token={token}
node={node}
canModify={canModify}
canDelete={canDelete}
onNotice={setNotice}
onConfirm={setConfirmation}
onAdd={() => openModal('add', '')}
onOpen={(z) => {
setNotice(null)
setOpen(z)
}}
onEditZoneFile={(z) => openModal('editZoneFile', z)}
onImport={(z) => openModal('import', z)}
onConvert={(z, t) => openModal('convert', z, t)}
onClone={(z) => openModal('clone', z)}
onPermissions={(z) => (hub ? setNotice({ type: 'info', title: 'Note!', text: "Zone permissions are changed in that server's own console." }) : openModal('permissions', z))}
onOptions={(z) => openModal('options', z)}
refresh={listRefresh}
/>
) : (
<ZoneRecords
zone={open}
token={token}
node={node}
canModify={canModify}
canDelete={canDelete}
onBack={() => {
setOpen(null)
refreshList()
}}
onNotice={setNotice}
onConfirm={setConfirmation}
onAddRecord={(zone, recs) => {
setRecordMode('add')
setZoneDetails(zone)
setRecords(recs)
setOriginalRecord(null)
openModal('record', open)
}}
onEditRecord={(zone, record, recs) => {
setRecordMode('update')
setZoneDetails(zone)
setRecords(recs)
setOriginalRecord(record)
openModal('record', open)
}}
onEditZoneFile={(z) => openModal('editZoneFile', z)}
onImport={(z) => openModal('import', z)}
onConvert={(z, t) => openModal('convert', z, t)}
onClone={(z) => openModal('clone', z)}
onPermissions={(z) => (hub ? setNotice({ type: 'info', title: 'Note!', text: "Zone permissions are changed in that server's own console." }) : openModal('permissions', z))}
onOptions={(z) => openModal('options', z)}
onSign={(z) => openModal('sign', z)}
onUnsign={(z) => openModal('unsign', z)}
onViewDs={(z) => openModal('viewds', z)}
onDnssecProperties={(z) => openModal('dnssec', z)}
refresh={zoneRefresh}
modalExpiryTtl={modalExpiryTtl}
/>
)}
<Confirm
open={confirmation !== null}
title={confirmation?.title ?? ''}
text={confirmation?.text}
label={confirmation?.label ?? ''}
variant={confirmation?.danger ? 'danger' : 'primary'}
onClose={() => setConfirmation(null)}
onConfirm={() => confirmation?.action()}
/>
<AddZone
open={modal === 'add'}
token={token}
node={node}
useSoaSerialDateScheme={useSoaSerialDateScheme}
dnssecValidation={dnssecValidation}
clusterDomain={clusterInitialised ? clusterDomain : undefined}
onClose={() => setModal(null)}
onCreated={(domain, a) => {
// Upstream opens the newly created zone, it does not go back to the list.
setOpen(domain === '' ? '.' : domain)
setNotice(a)
}}
/>
<EditZoneFile
zone={modalZone}
open={modal === 'editZoneFile'}
token={token}
node={node}
onClose={() => setModal(null)}
onSaved={zoneFileSaved}
/>
<ImportZone
zone={modalZone}
open={modal === 'import'}
token={token}
node={node}
onClose={() => setModal(null)}
// `importZone` (zone.js:1387-1393) reloads the zone only if its view is
// open, like the zone file editor; from the list it refreshes nothing.
onDone={zoneFileSaved}
/>
<CloneZone
zone={modalZone}
open={modal === 'clone'}
token={token}
node={node}
onClose={() => setModal(null)}
onDone={done}
/>
<ConvertZone
zone={modalZone}
sourceType={modalKind}
open={modal === 'convert'}
token={token}
node={node}
onClose={() => setModal(null)}
onDone={done}
/>
<ZoneOptions
zone={modalZone}
open={modal === 'options'}
token={token}
node={node}
onClose={() => setModal(null)}
onDone={done}
/>
<ZonePermissions
zone={modalZone}
open={modal === 'permissions'}
token={token}
node={node}
canModify={canModify}
onClose={() => setModal(null)}
onDone={done}
/>
<SignZone
zone={modalZone}
open={modal === 'sign'}
token={token}
node={node}
onClose={() => setModal(null)}
onDone={done}
/>
<UnsignZone
zone={modalZone}
open={modal === 'unsign'}
token={token}
node={node}
onClose={() => setModal(null)}
onDone={done}
/>
<ViewDs
zone={modalZone}
open={modal === 'viewds'}
token={token}
node={node}
onClose={() => setModal(null)}
/>
<DnssecProperties
zone={modalZone}
open={modal === 'dnssec'}
token={token}
node={node}
onClose={() => setModal(null)}
onConfirm={setConfirmation}
onChanged2={refreshZone}
/>
<AddEditRecord
open={modal === 'record'}
mode={recordMode}
zone={modalZone}
zoneInfo={zoneDetails}
records={records}
original={originalRecord}
token={token}
node={node}
defaults={{ ...recordTtls, dnssecValidation }}
onClose={() => setModal(null)}
onDone={done}
onExpiryTtl={setModalExpiryTtl}
/>
</>
)
}