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 <noreply@anthropic.com>
This commit is contained in:
1 parent
57e492cceb
commit
8bb1e01936
45 files changed
+1851
-190
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { useContext, useId, useRef, useState } from 'react'
|
||||
import { HubNode, nodeArg } from '../../lib/hub-write'
|
||||
import { addDomain, type DomainList } from '../../api/zonelists'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
import { Field, Input } from '../../ui/Field'
|
||||
@@ -51,6 +52,8 @@ export function AddDomainBar({
|
||||
onNotice: (n: Notice) => void
|
||||
onChanged: (list: DomainList, domain: string) => void
|
||||
}) {
|
||||
/* Behind a hub: the server the section has chosen (empty elsewhere, which sends no `node`). */
|
||||
const node = useContext(HubNode)
|
||||
const [field, setField] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const entry = useRef<HTMLInputElement>(null)
|
||||
@@ -82,7 +85,7 @@ export function AddDomainBar({
|
||||
checkDomain(token, domain, {
|
||||
settings: missing(permissions, 'Settings.canView'),
|
||||
allowed: missing(permissions, 'Allowed.canView'),
|
||||
}),
|
||||
}, ...nodeArg(node)),
|
||||
/* The names the Lists tab gives its rows: Technitium's catalogue, then ours. */
|
||||
loadQuickList('quick-block-lists').then((c) => [...c, ...moreLists(c)]),
|
||||
])
|
||||
@@ -120,7 +123,7 @@ export function AddDomainBar({
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
const outcome = await addDomain(list, token, domain)
|
||||
const outcome = await addDomain(list, token, domain, ...nodeArg(node))
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
onNotice(noticeFromFailure(outcome))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { useCallback, useContext, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { HubNode, nodeArg } from '../../lib/hub-write'
|
||||
import { readSettings, setSettings, forceUpdateBlockLists } from '../../api/settings'
|
||||
import { getDashboardStats } from '../../api/dashboard'
|
||||
import { loadQuickList, type QuickEntry } from '../../lib/quick-lists'
|
||||
@@ -209,7 +210,9 @@ export function BlockLists({
|
||||
/** Told whether there are unsaved changes, for the tab's mark; false on leaving. */
|
||||
onDirty?: (dirty: boolean) => void
|
||||
}) {
|
||||
const node = clusterInitialised ? 'cluster' : ''
|
||||
/* Behind a hub the lists are the chosen server's own (no cluster); elsewhere the cluster's. */
|
||||
const hubNode = useContext(HubNode)
|
||||
const node = hubNode !== '' ? hubNode : clusterInitialised ? 'cluster' : ''
|
||||
const viewNeed = missing(permissions, 'Settings.canView')
|
||||
const modifyNeed = missing(permissions, 'Settings.canModify')
|
||||
const statsNeed = missing(permissions, 'Dashboard.canView')
|
||||
@@ -461,7 +464,7 @@ export function BlockLists({
|
||||
// known if the read fails.
|
||||
const before = await readSettings(token, node)
|
||||
const from = before.kind !== 'ok' ? serverNext.current : (before.data.blockListNextUpdatedOn ?? null)
|
||||
const ok = await forceUpdateBlockLists(token)
|
||||
const ok = await forceUpdateBlockLists(token, ...nodeArg(hubNode))
|
||||
setBusy(false)
|
||||
if (!ok) return
|
||||
// main.js:2356. The label becomes "Updating Now" without reloading the settings.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { ClusterNodeSelect, HubMode } from '../../ui/ClusterNodeSelect'
|
||||
import { HubNode, useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import { getSettings } from '../../api/settings'
|
||||
import { getDashboardStats } from '../../api/dashboard'
|
||||
import { BlockingMenu } from '../dashboard/BlockingMenu'
|
||||
import { Tag } from '../../ui/Tag'
|
||||
import { missing } from './permissions'
|
||||
import { missing, withoutWrites } from './permissions'
|
||||
import { untilText, useBlockingState } from './blocking-state'
|
||||
import { SubTabs } from '../../ui/SubTabs'
|
||||
import { ruleSearch } from './rules-model'
|
||||
@@ -36,12 +39,12 @@ function dropRule(): void {
|
||||
|
||||
/* What the tabs count: the rules (blocked and allowed zones) and the lists. Each is read
|
||||
once and only when the session may read it; a failed read leaves the tab without one. */
|
||||
function useCounts(token: string | null, permissions: Permissions) {
|
||||
function useCounts(token: string | null, permissions: Permissions, node = '') {
|
||||
const [counts, setCounts] = useState<{ Rules?: number; Lists?: number }>({})
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
if (missing(permissions, 'Dashboard.canView') == null) {
|
||||
void getDashboardStats(token).then((o) => {
|
||||
void getDashboardStats(token, undefined, undefined, node).then((o) => {
|
||||
if (alive && o.kind === 'ok') {
|
||||
const s = o.data.stats
|
||||
setCounts((c) => ({ ...c, Rules: s.blockedZones + s.allowedZones }))
|
||||
@@ -49,7 +52,7 @@ function useCounts(token: string | null, permissions: Permissions) {
|
||||
})
|
||||
}
|
||||
if (missing(permissions, 'Settings.canView') == null) {
|
||||
void getSettings(token).then((s) => {
|
||||
void getSettings(token, node).then((s) => {
|
||||
if (alive && s != null) setCounts((c) => ({ ...c, Lists: s.blockListUrls?.length ?? 0 }))
|
||||
})
|
||||
}
|
||||
@@ -58,14 +61,14 @@ function useCounts(token: string | null, permissions: Permissions) {
|
||||
}
|
||||
// The permission object is read once: the counts are a hint, not a live figure.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token])
|
||||
}, [token, node])
|
||||
return counts
|
||||
}
|
||||
|
||||
/* The state strip of the canvas, above the tabs. Overview carries the full status block
|
||||
itself, so the strip is for the other two. */
|
||||
function Banner({ token, permissions }: { token: string | null; permissions: Permissions }) {
|
||||
const { state } = useBlockingState(token, permissions)
|
||||
function Banner({ token, permissions, node }: { token: string | null; permissions: Permissions; node: string }) {
|
||||
const { state } = useBlockingState(token, permissions, true, node)
|
||||
if (state == null || state === 'failed') return null
|
||||
const canModify = missing(permissions, 'Settings.canModify') == null
|
||||
return (
|
||||
@@ -82,7 +85,7 @@ function Banner({ token, permissions }: { token: string | null; permissions: Per
|
||||
</span>
|
||||
{canModify && (
|
||||
<span className={shared.bannerAct}>
|
||||
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} />
|
||||
<BlockingMenu token={token} text={state.kind === 'on' ? 'Temporarily Disable Blocking' : 'Enable Blocking'} onNotice={() => {}} node={node} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -131,11 +134,30 @@ export function Blocking({
|
||||
|
||||
const [listsDirty, setListsDirty] = useState(false)
|
||||
|
||||
const counts = useCounts(token, permissions)
|
||||
/*
|
||||
Behind a hub the Rules and Lists tabs act on ONE server, chosen here (the first at the start;
|
||||
Overview keeps its own selector, which also offers "All servers" for the figures). Where the
|
||||
hub cannot write (no admin token) the writes are switched off and the line under the selector
|
||||
says why. Outside a hub none of this exists: `hubNode` is empty and `permissions` are the role's.
|
||||
*/
|
||||
const hub = useContext(HubMode)
|
||||
const defaultNode = useHubDefaultNode(nodes)
|
||||
const [chosen, setChosen] = useState(defaultNode)
|
||||
const hubNode = hub ? chosen : ''
|
||||
const write = useHubWrite(hubNode)
|
||||
const effective = write.allowed ? permissions : withoutWrites(permissions)
|
||||
|
||||
const counts = useCounts(token, permissions, hubNode)
|
||||
|
||||
const tabs = (
|
||||
<>
|
||||
{active !== 'Overview' && <Banner token={token} permissions={permissions} />}
|
||||
{hub && active !== 'Overview' && (
|
||||
<>
|
||||
<ClusterNodeSelect nodes={nodes} initialised={clusterInitialised} value={chosen} onChange={setChosen} />
|
||||
<HubWriteHint write={write} />
|
||||
</>
|
||||
)}
|
||||
{active !== 'Overview' && <Banner token={token} permissions={effective} node={hubNode} />}
|
||||
<SubTabs
|
||||
label="Blocking sections"
|
||||
section="blocking"
|
||||
@@ -149,17 +171,23 @@ export function Blocking({
|
||||
)
|
||||
|
||||
if (active === 'Rules') {
|
||||
return <Rules tabs={tabs} token={token} permissions={permissions} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
return (
|
||||
<HubNode.Provider value={hubNode}>
|
||||
<Rules tabs={tabs} token={token} permissions={effective} nodes={nodes} clusterInitialised={clusterInitialised} />
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
if (active === 'Lists') {
|
||||
return (
|
||||
<HubNode.Provider value={hubNode}>
|
||||
<BlockLists
|
||||
tabs={tabs}
|
||||
token={token}
|
||||
permissions={permissions}
|
||||
permissions={effective}
|
||||
clusterInitialised={clusterInitialised}
|
||||
onDirty={setListsDirty}
|
||||
/>
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
getDashboardStats, RANGES, RANGE_LABEL,
|
||||
type ChartData, type DashboardStats, type Range,
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import { percentage, hasData, Legend, useHidden } from '../dashboard/Dashboard'
|
||||
import { Chart } from '../dashboard/Chart'
|
||||
import { InChrome } from '../../app/ChromeSlot'
|
||||
import { ClusterNodeSelect, AGGREGATE, type ClusterNode } from '../../ui/ClusterNodeSelect'
|
||||
import { ClusterNodeSelect, AGGREGATE, HubMode, type ClusterNode } from '../../ui/ClusterNodeSelect'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
import { Segmented } from '../../ui/Segmented'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
@@ -20,7 +20,9 @@ import { TopTable } from './TopTable'
|
||||
import { RecentBlocked } from './RecentBlocked'
|
||||
import { LAST_HOUR_REFRESH_MS, useLiveView } from '../dashboard/useLive'
|
||||
import { Locked } from './Locked'
|
||||
import { missing, type Permissions } from './permissions'
|
||||
import { missing, withoutWrites, type Permissions } from './permissions'
|
||||
import { HubNode, useHubWrite } from '../../lib/hub-write'
|
||||
import { HubWriteHint } from '../../ui/HubWriteHint'
|
||||
import shared from './Blocking.module.css'
|
||||
import styles from './Overview.module.css'
|
||||
|
||||
@@ -181,7 +183,7 @@ function Gap({ failure, loading, announce = true }: { failure: boolean; loading:
|
||||
export function Overview({
|
||||
tabs,
|
||||
token,
|
||||
permissions,
|
||||
permissions: rolePermissions,
|
||||
nodes = [],
|
||||
clusterInitialised = false,
|
||||
serverDomain,
|
||||
@@ -193,9 +195,14 @@ export function Overview({
|
||||
clusterInitialised?: boolean
|
||||
serverDomain?: string
|
||||
}) {
|
||||
const statsNeed = missing(permissions, 'Dashboard.canView')
|
||||
const statsNeed = missing(rolePermissions, 'Dashboard.canView')
|
||||
const [range, setRange] = useState<Period>('LastHour')
|
||||
const [node, setNode] = useState<string>(() => localStorage.getItem('dashboardClusterNode') || AGGREGATE)
|
||||
/* Behind a hub, a change is allowed only on a chosen server the hub can write to: with "All servers"
|
||||
the controls are switched off like a missing permission, and the line under the selector says why. */
|
||||
const hub = useContext(HubMode)
|
||||
const write = useHubWrite(node)
|
||||
const permissions = write.allowed ? rolePermissions : withoutWrites(rolePermissions)
|
||||
const [data, setData] = useState<DashboardStats | null>(null)
|
||||
/** The first read of this period and node failed: there is nothing to show. */
|
||||
const [failure, setFailure] = useState(false)
|
||||
@@ -318,7 +325,7 @@ export function Overview({
|
||||
const loading = data == null && !failure
|
||||
|
||||
return (
|
||||
<>
|
||||
<HubNode.Provider value={hub ? node : ''}>
|
||||
<InChrome>
|
||||
<ClusterNodeSelect
|
||||
nodes={nodes}
|
||||
@@ -328,6 +335,7 @@ export function Overview({
|
||||
onChange={setNode}
|
||||
/>
|
||||
</InChrome>
|
||||
<HubWriteHint write={write} />
|
||||
|
||||
<SectionHeader
|
||||
title="Blocking"
|
||||
@@ -465,6 +473,6 @@ export function Overview({
|
||||
refreshOn={s?.totalBlocked}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</HubNode.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { HubNode, nodeArg } from '../../lib/hub-write'
|
||||
import { readRuleExport } from '../../api/blocking'
|
||||
import { addDomain, deleteDomain, exportDomains, flushList, type DomainList } from '../../api/zonelists'
|
||||
import { SectionHeader } from '../../ui/SectionHeader'
|
||||
@@ -89,6 +90,8 @@ export function Rules({
|
||||
nodes?: { name: string; type: string }[]
|
||||
clusterInitialised?: boolean
|
||||
}) {
|
||||
/* Behind a hub: the server the section has chosen (empty elsewhere). */
|
||||
const hubNode = useContext(HubNode)
|
||||
const viewBlocked = missing(permissions, 'Blocked.canView') == null
|
||||
const viewAllowed = missing(permissions, 'Allowed.canView') == null
|
||||
/* Neither list can be read: there is nothing to count, so nothing is counted;
|
||||
@@ -162,7 +165,8 @@ export function Rules({
|
||||
change, the PRIMARY, where the change was made (other-zones.js:269, 434): a
|
||||
secondary still answers the old list until the cluster syncs. */
|
||||
const load = useCallback(async (fromPrimary = false) => {
|
||||
const node = fromPrimary ? primary.current : ''
|
||||
/* Behind a hub every read and change is on the server the section has chosen. */
|
||||
const node = hubNode !== '' ? hubNode : fromPrimary ? primary.current : ''
|
||||
const mine = ++asked.current
|
||||
const read = (list: DomainList, may: boolean) => (may ? readRuleExport(list, token, node) : null)
|
||||
const [b, a] = await Promise.all([read('blocked', viewBlocked), read('allowed', viewAllowed)])
|
||||
@@ -181,7 +185,7 @@ export function Rules({
|
||||
setFailed(false)
|
||||
setStale(false)
|
||||
setLastGood(new Date().toISOString())
|
||||
}, [token, viewBlocked, viewAllowed])
|
||||
}, [token, viewBlocked, viewAllowed, hubNode])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
@@ -252,7 +256,7 @@ export function Rules({
|
||||
label: 'Delete',
|
||||
action: () =>
|
||||
mutate(
|
||||
() => deleteDomain(rule.list, token, rule.domain),
|
||||
() => deleteDomain(rule.list, token, rule.domain, ...nodeArg(hubNode)),
|
||||
isAllowed
|
||||
? { type: 'success', title: 'Deleted!', text: `Domain '${rule.domain}' was deleted from Allowed Zone successfully.` }
|
||||
: { type: 'success', title: 'Deleted!', text: `Blocked zone '${rule.domain}' was deleted successfully.` },
|
||||
@@ -268,7 +272,7 @@ export function Rules({
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const outcome = await deleteDomain(rule.list, token, rule.domain)
|
||||
const outcome = await deleteDomain(rule.list, token, rule.domain, ...nodeArg(hubNode))
|
||||
if (!mounted.current) return
|
||||
setBusy(false)
|
||||
if (outcome.kind !== 'ok') {
|
||||
@@ -285,7 +289,7 @@ export function Rules({
|
||||
const deleted = undo.rules
|
||||
setUndo({ rules: deleted, phase: 'restoring' })
|
||||
setBusy(true)
|
||||
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain))
|
||||
const result = await restoreRules(deleted, (r) => addDomain(r.list, token, r.domain, ...nodeArg(hubNode)))
|
||||
if (!mounted.current) return
|
||||
setBusy(false)
|
||||
changed()
|
||||
@@ -309,7 +313,7 @@ export function Rules({
|
||||
label: 'Flush',
|
||||
action: () =>
|
||||
mutate(
|
||||
() => flushList(list, token),
|
||||
() => flushList(list, token, ...nodeArg(hubNode)),
|
||||
isAllowed
|
||||
? { type: 'success', title: 'Flushed!', text: 'Allowed zone was flushed successfully.' }
|
||||
: { type: 'success', title: 'Flushed!', text: 'Blocked zone was flushed successfully.' },
|
||||
@@ -319,7 +323,7 @@ export function Rules({
|
||||
|
||||
async function runExport(list: DomainList) {
|
||||
setBusy(true)
|
||||
const r = await exportDomains(list, token)
|
||||
const r = await exportDomains(list, token, ...nodeArg(hubNode))
|
||||
setBusy(false)
|
||||
if (!r.ok) return
|
||||
setNotice({
|
||||
@@ -454,7 +458,7 @@ export function Rules({
|
||||
are framed boxes of their own, and flush they doubled the panel's edge. */
|
||||
<div className={styles.tree}>
|
||||
<Lists
|
||||
key={`${treeList}:${generation}`}
|
||||
key={`${treeList}:${generation}:${hubNode}`}
|
||||
list={treeList}
|
||||
token={token}
|
||||
nodes={nodes}
|
||||
@@ -617,6 +621,7 @@ export function Rules({
|
||||
list={importing}
|
||||
open
|
||||
token={token}
|
||||
node={hubNode}
|
||||
onClose={() => setImporting(null)}
|
||||
onDone={(n) => {
|
||||
setNotice(n)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useContext } from 'react'
|
||||
import { HubNode } from '../../lib/hub-write'
|
||||
import { BlockingMenu } from '../dashboard/BlockingMenu'
|
||||
import { Panel, Body } from '../../ui/Panel'
|
||||
import { PermissionButton } from '../../ui/PermissionButton'
|
||||
@@ -44,7 +46,9 @@ export function StatusPanel({
|
||||
/* The reads (when, from where, and the one when a pause ends) are
|
||||
`useBlockingState`'s, shared with the sidebar's mark. A change made from the
|
||||
menu below reaches it as every settings write does (`lib/settings-signal.ts`). */
|
||||
const { state, retry } = useBlockingState(token, permissions)
|
||||
/* Behind a hub: the server the section has chosen. */
|
||||
const node = useContext(HubNode)
|
||||
const { state, retry } = useBlockingState(token, permissions, true, node)
|
||||
|
||||
if (viewNeed != null) return <Locked title="Blocking" need={viewNeed} />
|
||||
|
||||
@@ -109,7 +113,7 @@ export function StatusPanel({
|
||||
{modifyNeed != null ? (
|
||||
<PermissionButton permission={modifyNeed}>{verb}</PermissionButton>
|
||||
) : (
|
||||
<BlockingMenu token={token} text={verb} onNotice={onNotice} />
|
||||
<BlockingMenu token={token} text={verb} onNotice={onNotice} node={node} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -62,6 +62,8 @@ export function useBlockingState(
|
||||
/** False reads nothing, as a missing `Settings.canView` does: the sidebar without
|
||||
a Blocking entry to mark. */
|
||||
enabled = true,
|
||||
/** Behind a hub: the server read (a hub has no settings of its own). Empty elsewhere. */
|
||||
node = '',
|
||||
): { state: BlockingRead; reload: () => void; retry: () => void } {
|
||||
const viewNeed = enabled ? missing(permissions, 'Settings.canView') : 'Settings.canView'
|
||||
const [state, setState] = useState<BlockingRead>(null)
|
||||
@@ -70,11 +72,11 @@ export function useBlockingState(
|
||||
const reload = useCallback(() => {
|
||||
if (viewNeed != null) return
|
||||
const mine = ++asked.current
|
||||
void getSettings(token).then((s) => {
|
||||
void getSettings(token, node).then((s) => {
|
||||
if (mine !== asked.current) return
|
||||
setState(s == null ? 'failed' : stateOf(s.enableBlocking, s.temporaryDisableBlockingTill))
|
||||
})
|
||||
}, [token, viewNeed])
|
||||
}, [token, viewNeed, node])
|
||||
|
||||
useEffect(() => {
|
||||
// A read already travelling must not land after the permission went.
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ApiOutcome } from '../../api/client'
|
||||
import { readRuleExport } from '../../api/blocking'
|
||||
import { resolve } from '../../api/dnsclient'
|
||||
import { readSettings } from '../../api/settings'
|
||||
import { nodeArg } from '../../lib/hub-write'
|
||||
import type { QuickEntry } from '../../lib/quick-lists'
|
||||
import { listName } from './list-lines'
|
||||
|
||||
@@ -321,11 +322,13 @@ export async function checkDomain(
|
||||
token: string | null,
|
||||
domain: string,
|
||||
needs: { settings?: string; allowed?: string },
|
||||
/** Behind a hub: the server asked (a hub has no server of its own to ask). */
|
||||
node = '',
|
||||
): Promise<ApiOutcome<CheckInputs>> {
|
||||
const [answer, settings, allowed] = await Promise.all([
|
||||
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '' }),
|
||||
needs.settings != null ? { need: needs.settings } : readSettings(token),
|
||||
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token),
|
||||
resolve(token, { server: 'this-server', domain, type: 'A', protocol: 'UDP', dnssec: false, eDnsClientSubnet: '', ...(node === '' ? {} : { node }) }),
|
||||
needs.settings != null ? { need: needs.settings } : readSettings(token, ...nodeArg(node)),
|
||||
needs.allowed != null ? { need: needs.allowed } : readRuleExport('allowed', token, ...nodeArg(node)),
|
||||
])
|
||||
if (answer.kind !== 'ok') return answer
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,20 @@ export function missing(perms: Permissions, ...needs: Need[]): Need | undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
/*
|
||||
Behind a hub, when the chosen node is not one a change can be made on ("All servers", or a server
|
||||
the hub holds no admin token for): the same permissions with every write switched off. The
|
||||
controls then render as they do for a role without the permission.
|
||||
*/
|
||||
export function withoutWrites(perms: Permissions): Permissions {
|
||||
if (perms == null) return perms
|
||||
const out: Record<string, Permission> = { ...perms }
|
||||
for (const section of ['Allowed', 'Blocked', 'Settings']) {
|
||||
out[section] = { canView: perms[section]?.canView ?? true, canModify: false, canDelete: false }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The same sentence `PermissionButton` draws: `Requires Logs: View`. */
|
||||
export function requiresText(need: string): string {
|
||||
const [section, action] = need.split('.')
|
||||
|
||||
Reference in new issue
Block a user