import { useEffect, useMemo, useRef, useState } from 'react' import { visibleSections, type Permission } from './sections' import { toTrail, writeRoute, readRoute, translateLegacyRoute, plainClick } from './route' import { ChangePassword } from '../screens/modals/ChangePassword' import { ChangeTheme } from '../screens/modals/ChangeTheme' import { Configure2FA } from '../screens/modals/Configure2FA' import { CreateApiToken } from '../screens/modals/CreateApiToken' import { MyProfile } from '../screens/modals/MyProfile' import { Dashboard } from '../screens/dashboard/Dashboard' import { DnsClient } from '../screens/dnsclient/DnsClient' import { About } from '../screens/about/About' import { Apps } from '../screens/apps/Apps' import { Cache } from '../screens/lists/Lists' import { Blocking } from '../screens/blocking/Blocking' import { Settings, type RecordSettings, type ServerInfo } from '../screens/settings/Settings' import { Zones } from '../screens/zones/Zones' import { Dhcp } from '../screens/dhcp/Dhcp' import { Logs } from '../screens/logs/Logs' import { Admin } from '../screens/admin/Admin' import { SlotProvider } from './ChromeSlot' import { HandoffContext, targetNode, type DnsClientRequest, type Handoff, type QueryLogsRequest, } from './handoff' import styles from './Shell.module.css' import { Icon, type IconName } from '../ui/Icon' import { publicUrl } from './base' import { Menu } from '../ui/Menu' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { ThisServerName } from '../ui/ClusterNodeSelect' import { FooterLinks } from '../ui/FooterLinks' import { Versions } from './Versions' import { BlockingMark } from './BlockingMark' import { useBlockingMark } from '../screens/blocking/blocking-state' import { Confirm } from '../ui/Confirm' import { Notifier } from '../ui/Notifier' import type { Notice } from '../lib/notice' import { silenceUpdateNotification, unsilenceUpdateNotification, updateNotificationSilenced, } from '../api/user' type ModalId = 'profile' | 'password' | 'twofa' | 'token' | 'theme' /** One glyph per section. No icon dependency: the server's CSP does not allow a * CDN and an icon font would have to ship as a file in www/. */ /* The section icons live in `ui/Icon`, drawn; here we only say which one each section carries. Blocking carries Blocked's: it is the section that took its place. */ /* The sidebar's three groups. It is not a new taxonomy: it is upstream's own order, with a gap where the kind of task already changed. What you operate daily, what you configure and what you consult. */ const GROUPS: { name: string; ids: string[] }[] = [ { name: 'DNS', ids: ['dashboard', 'zones', 'cache', 'blocking', 'apps', 'dnsclient'] }, { name: 'Server', ids: ['settings', 'dhcp', 'admin', 'logs', 'about'] }, ] /* The sidebar's own preferences, per browser, as the canvas keeps them: the rail and the pinned sections. A browser that refuses storage starts from the defaults. */ function stored(key: string, fallback: T): T { try { const v = localStorage.getItem(key) return v == null ? fallback : (JSON.parse(v) as T) } catch { return fallback } } function keep(key: string, value: unknown) { try { localStorage.setItem(key, JSON.stringify(value)) } catch { /* nothing to do */ } } const ICONS: Record = { dashboard: 'dashboard', zones: 'zones', cache: 'cache', blocking: 'blocked', apps: 'apps', dnsclient: 'dnsclient', settings: 'settings', dhcp: 'dhcp', admin: 'admin', logs: 'logs', about: 'about', } export interface ShellSession { token: string displayName: string username: string /** `Local`, `RemoteSSO` or `RemoteLDAP` since v15.5 (flat in `user/session/get` * and `user/login`, WebServiceAuthApi.cs:74). */ type?: string /** Obsolete since v15.5 (line 75): still sent, no longer read. */ isSsoUser?: boolean /** Omitted for a `RemoteSSO` user (lines 77-82). */ totpEnabled?: boolean info?: { version: string uptimestamp: string dnsServerDomain: string permissions?: Record clusterInitialized?: boolean /** Only on an initialised cluster (WebServiceAuthApi.cs:104-106). */ clusterDomain?: string clusterNodes?: { name: string; type: string }[] /* The record defaults the zone forms show (WebServiceAuthApi.cs:96-100). */ defaultRecordTtl?: number defaultNsRecordTtl?: number defaultSoaRecordTtl?: number useSoaSerialDateScheme?: boolean dnssecValidation?: boolean } } export function Shell({ session, onLogout, forcePasswordChange = false, }: { session: ShellSession onLogout: () => void /** A factory-credentials login: Change Password opens at once with `admin` filled in (auth.js:283-284). */ forcePasswordChange?: boolean }) { const permissions = session.info?.permissions // Memoised: if it is recreated on every render, the `hashchange` resubscribes on each. const sections = useMemo(() => visibleSections(permissions), [permissions]) /* The starting section comes from the address bar if it carries one, and only if not, from the first visible one. See `app/route.ts` for the reasoning. */ /* Read once, on mount. `/allowed/` and `/blocked/` are Blocking's Rules tab now: the bar is rewritten (`replaceState`) before the route is read, here in the initialiser and not in the render body, so the side effect runs once; see `app/route.ts`. */ const [initialRoute] = useState(() => { translateLegacyRoute() return readRoute(sections) }) const [active, setActive] = useState(() => initialRoute?.section ?? sections[0]?.id ?? 'about') const [drawer, setDrawer] = useState(false) const [rail, setRail] = useState(() => stored('shell.rail', false)) const [shut, setShut] = useState([]) const [pins, setPins] = useState(() => stored('shell.pins', [])) const [palette, setPalette] = useState(false) function toggleRail() { setRail((v) => { keep('shell.rail', !v) return !v }) } function togglePin(id: string) { setPins((v) => { const next = v.includes(id) ? v.filter((x) => x !== id) : [...v, id] keep('shell.pins', next) return next }) } useEffect(() => { function onKey(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault() setPalette((v) => !v) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, []) const [modal, setModal] = useState(forcePasswordChange ? 'password' : null) /* The factory password travels only with the opening the login caused; from the user menu `showChangePasswordModal()` is called with nothing (main.js). */ const [forcedCurrentPassword, setForcedCurrentPassword] = useState( forcePasswordChange ? 'admin' : undefined, ) const [sub, setSub] = useState(initialRoute?.sub ?? null) const [displayName, setDisplayName] = useState(session.displayName) const [totpEnabled, setTotpEnabled] = useState(session.totpEnabled ?? false) /* Silencing the update notice (`main.js:702-732`), which this console could read and not write: `checkForUpdate` already refused to call the endpoint while the preference was on, but nothing could ever turn it on, so the branch was unreachable and the user had no way to stop being told. Restored on 2026-09-04, checking About against upstream. It is a per-browser preference and not a server setting, which is why it lives in `localStorage` and why silencing it on your laptop does not silence it anywhere else. */ const [silenced, setSilenced] = useState(() => updateNotificationSilenced()) const [askSilence, setAskSilence] = useState(false) const [chromeNotice, setChromeNotice] = useState(null) /* Once hidden in a session, the mark does not come back, and that is upstream's behaviour, not an oversight of ours: `disableUpdateNotification` calls `$("#lnkUpdateAvailable").hide()` and `enableUpdateNotification` never shows it again. You get the notice back by logging in again. Replicated as it stands, the same way `Quick Add`'s substring comparison is. */ const [markHidden, setMarkHidden] = useState(false) function silence() { setAskSilence(false) silenceUpdateNotification() setSilenced(true) setMarkHidden(true) setChromeNotice({ type: 'success', title: 'Notification Disabled!', text: 'Update notification was disabled successfully.', }) } /* Enabling does NOT re-check, exactly as upstream: it writes the preference and says so. The next check is the next login. */ function unsilence() { unsilenceUpdateNotification() setSilenced(false) setChromeNotice({ type: 'success', title: 'Notification Enabled!', text: 'Update notification was enabled successfully.', }) } function open(id: ModalId) { setModal(id) } /* `sessionData.info` is not fixed at login upstream: every load of this server's settings, and every save or restore of them, rewrites its domain, version and start time and redraws the tab title, the header and About (`updateDnsSettingsDataAndGui`, main.js:1158-1166). Settings reports them here. */ const [serverInfo, setServerInfo] = useState(null) /* The record defaults, from login until Settings loads them again (main.js:1194-1196 and 1228). */ const [recordSettings, setRecordSettings] = useState(null) const info = session.info && serverInfo ? { ...session.info, ...serverInfo } : session.info // main.js: the document title carries the server domain and the version. const title = info ? `${info.dnsServerDomain} - Technitium DNS Server v${info.version}` : null useEffect(() => { if (title != null) document.title = title }, [title]) const current = sections.find((s) => s.id === active) ?? sections[0] /* Whether blocking is off or paused, marked on its entry (`BlockingMark.tsx`). */ const blockingNow = useBlockingMark(session.token, permissions, sections.some((s) => s.id === 'blocking')) /* The jumps other screens' row menus make (`app/handoff.ts`). The section switch and the request land in the same render, so the target mounts already holding it; the effect below then forgets it, because the target has taken what it needs, and coming back later through the sidebar must find a clean screen. A section the user cannot see is not opened: there would be no screen to land on. */ const [pending, setPending] = useState< { dnsclient: DnsClientRequest } | { logs: QueryLogsRequest } | null >(null) useEffect(() => { if (pending != null) setPending(null) }, [pending]) const handoff = useMemo(() => { const visible = (id: string) => sections.some((s) => s.id === id) return { queryDnsServer(domain, type, node) { if (!visible('dnsclient')) return setPending({ dnsclient: { domain, type: type ?? 'A', node: targetNode(node) } }) setActive('dnsclient') setSub(null) setDrawer(false) }, showQueryLogs(domain, clientIp, node, filters) { if (!visible('logs')) return setPending({ logs: { domain, clientIp, node: targetNode(node), ...(filters != null ? { filters } : {}) } }) setActive('logs') setSub('Query Logs') setDrawer(false) }, canShowQueryLogs: visible('logs'), } }, [sections]) /* The effective sub. A section with sub-sections is ALWAYS in one of them, so `null` means "the first one". It is resolved here and not in each branch (there were five loose `sub ?? 'General'`, and the sidebar repeated the same workaround to decide what to mark) because a rule spread around is precisely the one that goes out of sync: the menu said "General" and the address bar said `/settings/`, which is half a page. */ const currentSub = current?.subs != null ? (sub ?? current.subs[0] ?? null) : null /* The URL follows the state, and the state follows the URL. The guard is in `writeRoute`, which does not touch history if the route is already the current one: without it, responding to `popstate` by setting the state would write the route again. */ // The first write normalises the URL and must leave no history entry. const firstRender = useRef(true) const activeRef = useRef({ section: current?.id ?? 'about', sub }) useEffect(() => { if (current == null) return activeRef.current = { section: current.id, sub: currentSub } writeRoute({ section: current.id, sub: currentSub }, firstRender.current) firstRender.current = false }, [current, sub, currentSub]) useEffect(() => { function onChanged() { translateLegacyRoute() const r = readRoute(sections) if (r == null) { // A route that does not resolve left the bar and the screen saying different // things. The screen rules: the URL is corrected. writeRoute({ section: activeRef.current.section, sub: activeRef.current.sub }, true) return } /* A half address (`/settings/`, with no sub) is completed here and not in the effect that writes the route: on going "back" the state does not change (`sub` was already `null`), so that effect does not run again and the bar was left saying half a page. And it is completed by replacing: pushing would leave a new entry pointing at where we already are, and the back button would go round in circles. */ const section = sections.find((x) => x.id === r.section) const own = r.sub ?? section?.subs?.[0] ?? null if (own !== r.sub) writeRoute({ section: r.section, sub: own }, true) setActive((v) => (v === r.section ? v : r.section)) setSub((v) => (v === own ? v : own)) } window.addEventListener('popstate', onChanged) return () => window.removeEventListener('popstate', onChanged) }, [sections]) const pinned = sections.filter((sec) => pins.includes(sec.id)) function go(id: string, to: string | null) { setActive(id) setSub(to) setDrawer(false) setPalette(false) } function navRow(sec: (typeof sections)[number], where: 'pin' | 'main') { const here = sec.id === current?.id const first = sec.subs?.[0] ?? null const pin = pins.includes(sec.id) return ( ) } const group = GROUPS.find((g) => current != null && g.ids.includes(current.id))?.name const crumbs = ['Home', group, current?.label, currentSub].filter((x): x is string => !!x) return ( {(slot) => (
{drawer && ( {/* The server and the account, at the foot. When narrow you reach it via the drawer. */}
{/* Upstream's footer links, recovered. Upstream has them in a `div#footer` hanging off the `body`, so they are visible on ALL its screens, the login one included: Technitium, Blog, Donate, DNS Client and GitHub. There were none here, and two of them (technitium.com and dnsclient.net) appeared nowhere else in the console: they had been lost entirely. "About" is not repeated because in this redesign it is a sidebar section, right above. */} {/* What is running, and whether there is anything newer. The server's domain heads it because the block is about that server, which is the pairing upstream makes in its panel title. */}
{/* On a WIDE screen there is no header: it only carried the server domain and the account menu, both pinned to the right edge, and it measured 1224×52 with 78 % of the width empty (952 px out of 1224), pushing every section's title down to `y=76`. Both things move down to the foot of the sidebar, which had 377 px free, 42 % of its height. On a NARROW screen it is needed, because the sidebar is a closed drawer and its trigger has to live outside. */}
{crumbs.map((c, i) => ( {c} ))}
{blockingNow != null ? 'Attention' : (info?.dnsServerDomain ?? 'Server')}
Server
{info?.dnsServerDomain ?? '-'}
Version
{info?.version ?? '-'}
Up since
{info?.uptimestamp ? new Date(info.uptimestamp).toLocaleString() : '-'}
Blocking
{blockingNow ? blockingNow.label.replace(/^Blocking,?\s*/, '') : 'Enabled'}
{/* Neither `tabpanel` nor `aria-labelledby`: ever since each section has its own URL this is not a panel being switched, it is the page. */}
{/* The chrome slot: the chrome provides the PLACE, the screen provides the control. See `ChromeSlot.tsx`: the node selector belongs to each screen, with its memory and its options, and none of them hears about the others. It stays empty while nobody uses it, and then it takes up nothing: it is a `div` with no content. */}
{/* The chrome's own alert slot. Upstream's `showAlert` with no destination is a page-level alert, and until now the chrome had nowhere to put one. */} setChromeNotice(null)} /> {current?.id === 'dashboard' ? ( ) : current?.id === 'dnsclient' ? ( ) : current?.id === 'about' ? ( ) : current?.id === 'apps' ? ( ) : current?.id === 'cache' ? ( ) : current?.id === 'blocking' ? ( ) : current?.id === 'zones' ? ( ) : current?.id === 'dhcp' ? ( ) : current?.id === 'logs' ? ( /* "Delete All Stats" lives on the Logs screen but asks for the Dashboard's permission (WebServiceLogsApi.cs:135). Not an oversight. */ ) : current?.id === 'admin' ? ( /* No permission props on purpose: upstream hides and disables nothing inside Administration, only the whole section (main.js:165). */ ) : current?.id === 'settings' ? ( /* main.js:906-930: three different permissions in a single bar: saving requires Settings.canModify, flushing the cache Cache.canDelete, and backup/restore Settings.canDelete. */ ) : null}
{/* Upstream's links, at the foot of the page and not in the side panel. Upstream hangs them off a `div#footer` on the `body`, below everything: they are destinations you go to when you have finished, not navigation you use while working. In the side panel they sat between the sections and the account, competing with both; here they close the page, which is where they were. */}
Nothing found. {sections.flatMap((sec) => [ go(sec.id, sec.subs?.[0] ?? null)}> {sec.label} , ...(sec.subs ?? []).map((sb) => ( go(sec.id, sb)}> {sec.label} / {sb} )), ])} { setPalette(false); open('profile') }}>My Profile { setPalette(false); open('theme') }}>Change Theme { setPalette(false); onLogout() }}>Logout setModal(o ? 'profile' : null)} token={session.token} onSaved={setDisplayName} onNotice={setChromeNotice} /> { setModal(o ? 'password' : null) if (!o) setForcedCurrentPassword(undefined) }} totpEnabled={totpEnabled} token={session.token} username={session.username} currentPassword={forcedCurrentPassword} onNotice={setChromeNotice} /> setModal(o ? 'twofa' : null)} token={session.token} username={session.username} onChanged={setTotpEnabled} onNotice={setChromeNotice} /> setModal(o ? 'token' : null)} username={session.username} token={session.token} /> setModal(o ? 'theme' : null)} /> {/* Upstream asks with a native `confirm()`; this console asks with its own, and the sentence is upstream's, whole. */} setAskSilence(false)} onConfirm={silence} />
)}
) }