From ec3f6daa462ed1b6f82e2117040ca97bededf68a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 21:41:58 -0700 Subject: [PATCH] Rewrite the panel, sidebar, top bar, page routing and layout rules --- src/components/common/IconTile.tsx | 12 +- src/components/layout/MainContent.tsx | 660 +++++++++--------------- src/components/layout/Sidebar.tsx | 423 ++++++--------- src/components/layout/TopBar.tsx | 483 ++++++++--------- src/components/layout/pageRoute.test.ts | 65 +++ src/components/layout/pageRoute.ts | 48 ++ src/lib/iconByName.test.ts | 21 + src/lib/iconByName.ts | 16 + src/lib/layout.ts | 329 ++++-------- src/lib/layoutRules.test.ts | 186 +++++++ src/lib/navTree.test.ts | 99 +++- src/lib/navTree.ts | 109 ++-- src/lib/shortcuts.test.ts | 31 ++ src/lib/shortcuts.ts | 7 + src/pages/AdminPanel.tsx | 312 ++++------- src/pages/adminRoute.test.ts | 232 +++++++++ src/pages/adminRoute.ts | 185 +++++++ 17 files changed, 1782 insertions(+), 1436 deletions(-) create mode 100644 src/components/layout/pageRoute.test.ts create mode 100644 src/components/layout/pageRoute.ts create mode 100644 src/lib/iconByName.test.ts create mode 100644 src/lib/iconByName.ts create mode 100644 src/lib/layoutRules.test.ts create mode 100644 src/lib/shortcuts.test.ts create mode 100644 src/pages/adminRoute.test.ts create mode 100644 src/pages/adminRoute.ts diff --git a/src/components/common/IconTile.tsx b/src/components/common/IconTile.tsx index 73ea4ca..d5b83ea 100644 --- a/src/components/common/IconTile.tsx +++ b/src/components/common/IconTile.tsx @@ -5,7 +5,7 @@ */ import { createElement } from 'react'; -import * as LucideIcons from 'lucide-react'; +import { iconByName } from '@/lib/iconByName'; import { cn } from '@/lib/utils'; import { toneFor, type Tone } from '@/lib/iconTones'; @@ -21,14 +21,6 @@ const TONE_CLASSES: Record = { slate: 'bg-slate-500/15 text-slate-700 dark:bg-slate-400/15 dark:text-slate-300', }; -function iconComponent(name: string): LucideIcons.LucideIcon { - const pascal = name - .split('-') - .map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s)) - .join(''); - return ((LucideIcons as Record)[pascal] as LucideIcons.LucideIcon | undefined) ?? LucideIcons.Circle; -} - /** * A section's icon on a small colored tile. The color comes from what the * section is about (see iconTones), so the same kind of thing looks the same @@ -49,7 +41,7 @@ export function IconTile({ - {createElement(iconComponent(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })} + {createElement(iconByName(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })} ); } diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 518063f..061e00f 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -1,180 +1,291 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { lazy, Suspense, useEffect, type ComponentType, type ReactNode } from 'react'; -import { useSchemaStore } from '@/stores/schemaStore'; -import { useCacheStore } from '@/stores/cacheStore'; -import { useAccountStore } from '@/stores/accountStore'; -import { resolveObject } from '@/lib/schemaResolver'; -import { DynamicList } from '@/components/lists/DynamicList'; -import { DynamicForm } from '@/components/forms/DynamicForm'; -import { DynamicViewPage } from '@/components/views/DynamicViewPage'; +import { createElement, lazy, Suspense, useEffect, type ComponentType, type ReactNode } from 'react'; + import { LoadingFallback } from '@/components/common/LoadingFallback'; -import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; -import { SpamSettingsPage } from '@/features/spamLevel/SpamSettingsPage'; -import { WebhookTestCard } from '@/features/events/WebhookTestCard'; -import { WebhooksPage } from '@/features/events/WebhooksPage'; -import { ListenersPage } from '@/features/ports/ListenersPage'; -import { MetricsPage } from '@/features/metricsConnect/MetricsPage'; +import { DynamicForm } from '@/components/forms/DynamicForm'; +import { DynamicList } from '@/components/lists/DynamicList'; +import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { AlertsPage } from '@/features/alerts/AlertsPage'; -import { ReportsPage } from '@/features/reports/ReportsPage'; -import { RetentionPage } from '@/features/retention/RetentionPage'; -import { LimitsSummary } from '@/features/limits/LimitsSummary'; -import { SenderChecksPage } from '@/features/senderChecks/SenderChecksPage'; -import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard'; -import { SendingSetupCard } from '@/features/sending/SendingSetupCard'; import { CertificateSetupCard } from '@/features/certificates/CertificateSetupCard'; +import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard'; +import { WebhooksPage } from '@/features/events/WebhooksPage'; +import { WebhookTestCard } from '@/features/events/WebhookTestCard'; +import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; import { DmarcSummaryCard } from '@/features/incomingReports/DmarcSummaryCard'; import { TlsSummaryCard } from '@/features/incomingReports/TlsSummaryCard'; +import { LimitsSummary } from '@/features/limits/LimitsSummary'; +import type { LimitObject } from '@/features/limits/rules'; +import { MetricsPage } from '@/features/metricsConnect/MetricsPage'; +import { ListenersPage } from '@/features/ports/ListenersPage'; +import { ReportsPage } from '@/features/reports/ReportsPage'; +import { RetentionPage } from '@/features/retention/RetentionPage'; +import { SenderChecksPage } from '@/features/senderChecks/SenderChecksPage'; +import { SendingSetupCard } from '@/features/sending/SendingSetupCard'; +import { SpamSettingsPage } from '@/features/spamLevel/SpamSettingsPage'; +import { useAccountStore } from '@/stores/accountStore'; +import { useCacheStore } from '@/stores/cacheStore'; +import { useSchemaStore } from '@/stores/schemaStore'; import type { Schema } from '@/types/schema'; -function lazyFeature(load: () => Promise, select: (module: M) => ComponentType

) { - return lazy(() => load().then((module) => ({ default: select(module) }))); +import { pageRoute } from './pageRoute'; + +/** A page loaded the first time it's opened. */ +function onDemand(load: () => Promise, pick: (module: M) => ComponentType

) { + return lazy(() => load().then((module) => ({ default: pick(module) }))); } -const DashboardView = lazyFeature( +const DashboardView = onDemand( () => import('@/features/dashboard/components/DashboardView'), (m) => m.DashboardView, ); -const DeliveryTracePage = lazyFeature( - () => import('@/features/troubleshoot/DeliveryTracePage'), - (m) => m.DeliveryTracePage, -); -const LiveTracingPage = lazyFeature( - () => import('@/features/tracing/components/LiveTracingPage'), - (m) => m.LiveTracingPage, -); -const TraceDetailView = lazyFeature( +const TraceDetailView = onDemand( () => import('@/features/tracing/components/TraceDetailView'), (m) => m.TraceDetailView, ); -const ConnectDnsPage = lazyFeature( +const ConnectDnsPage = onDemand( () => import('@/features/dns/ConnectDnsPage'), (m) => m.ConnectDnsPage, ); -const ActionPage = lazyFeature( +const ActionPage = onDemand( () => import('@/features/actions/ActionPage'), (m) => m.ActionPage, ); -const SecurityPage = lazyFeature( - () => import('@/features/security/SecurityPage'), - (m) => m.SecurityPage, -); -// inbuxa: Management › Compliance › Overview and Data Inventory -// (personal-data catalog spec, §8). -const ComplianceOverviewPage = lazyFeature( - () => import('@/features/compliance/ComplianceOverviewPage'), - (m) => m.ComplianceOverviewPage, -); -// INBUXA: DLP and mail flow rules (dlp-and-mail-flow-rules spec, §3) -const DlpRulesPage = lazyFeature( - () => import('@/features/dlp/RulesPage'), - (m) => m.DlpRulesPage, -); -const MailFlowRulesPage = lazyFeature( - () => import('@/features/dlp/RulesPage'), - (m) => m.MailFlowRulesPage, -); -// INBUXA: journaling (journaling spec, §3) -const JournalPage = lazyFeature( - () => import('@/features/journal/JournalPage'), - (m) => m.JournalPage, -); -const HeldMailPage = lazyFeature( - () => import('@/features/dlp/HeldMailPage'), - (m) => m.HeldMailPage, -); -const DataInventoryPage = lazyFeature( - () => import('@/features/compliance/DataInventoryPage'), - (m) => m.DataInventoryPage, -); -// inbuxa: Settings › Overview (settings-reorg spec). -const CertificateSetupPage = lazyFeature( - () => import('@/features/certificates/CertificateSetupPage'), - (m) => m.CertificateSetupPage, -); -const SendingSetupPage = lazyFeature( - () => import('@/features/sending/SendingSetupPage'), - (m) => m.SendingSetupPage, -); -const SettingsOverviewPage = lazyFeature( - () => import('@/features/settings/SettingsOverviewPage'), - (m) => m.SettingsOverviewPage, -); -// inbuxa: guided setup, connect a sign-in directory (settings-reorg spec). -const DirectorySetupPage = lazyFeature( - () => import('@/features/directory/DirectorySetupPage'), - (m) => m.DirectorySetupPage, -); -// inbuxa: guided setup, sending and receiving limits (settings-reorg spec). -const LimitsSetupPage = lazyFeature( - () => import('@/features/limits/LimitsSetupPage'), - (m) => m.LimitsSetupPage, -); -// inbuxa: Settings › Spam filter › Local AI (ai-spam-classification spec). -const LocalAiPage = lazyFeature( - () => import('@/features/ai/LocalAiPage'), - (m) => m.LocalAiPage, -); -// inbuxa: Management › Compliance › Audit Log (audit-hold-lock spec, AU-9). -const AuditLogPage = lazyFeature( - () => import('@/features/audit/AuditLogPage'), - (m) => m.AuditLogPage, -); -// inbuxa: Management › Compliance › Locked accounts (audit-hold-lock spec, AL-1). -const AccountLocksPage = lazyFeature( - () => import('@/features/lock/AccountLocksPage'), - (m) => m.AccountLocksPage, -); -// inbuxa: Management › Directory › Shared mailboxes (multi-account spec, MA-S4). -const SharedMailboxesPage = lazyFeature( - () => import('@/features/lock/SharedMailboxesPage'), - (m) => m.SharedMailboxesPage, -); -// inbuxa: Reports › Scheduled (scheduled-reports spec). -const ScheduledReportsPage = lazyFeature( - () => import('@/features/scheduledReports/ScheduledReportsPage'), - (m) => m.ScheduledReportsPage, -); -// inbuxa: Domains › Deliverability (deliverability spec, DL-17). -const DeliverabilityPage = lazyFeature( - () => import('@/features/deliverability/DeliverabilityPage'), - (m) => m.DeliverabilityPage, -); -const LegalHoldsPage = lazyFeature( - () => import('@/features/hold/LegalHoldsPage'), - (m) => m.LegalHoldsPage, -); -const HeldNotice = lazyFeature( +const HeldNotice = onDemand( () => import('@/features/hold/HeldNotice'), (m) => m.HeldNotice, ); -const AccountLockBanner = lazyFeature( +const AccountLockBanner = onDemand( () => import('@/features/lock/AccountLockBanner'), (m) => m.AccountLockBanner, ); -// inbuxa: who may share mail (multi-account spec, MA-C) -const SharingSwitches = lazyFeature( +const SharingSwitches = onDemand( () => import('@/features/sharing/SharingSwitches'), (m) => m.SharingSwitches, ); -const TenantLegacyProtocols = lazyFeature( +const TenantLegacyProtocols = onDemand( () => import('@/features/hardening/TenantLegacyProtocols'), (m) => m.TenantLegacyProtocols, ); +/** The console's own pages, by the name after `CustomComponent/`. */ +const CUSTOM_PAGES: Record = { + LiveDelivery: onDemand( + () => import('@/features/troubleshoot/DeliveryTracePage'), + (m) => m.DeliveryTracePage, + ), + LiveTracing: onDemand( + () => import('@/features/tracing/components/LiveTracingPage'), + (m) => m.LiveTracingPage, + ), + // The security to-do list, which holds the legacy-protocols switch. + LegacyProtocols: onDemand( + () => import('@/features/security/SecurityPage'), + (m) => m.SecurityPage, + ), + SettingsOverview: onDemand( + () => import('@/features/settings/SettingsOverviewPage'), + (m) => m.SettingsOverviewPage, + ), + LocalAi: onDemand( + () => import('@/features/ai/LocalAiPage'), + (m) => m.LocalAiPage, + ), + AuditLog: onDemand( + () => import('@/features/audit/AuditLogPage'), + (m) => m.AuditLogPage, + ), + AccountLocks: onDemand( + () => import('@/features/lock/AccountLocksPage'), + (m) => m.AccountLocksPage, + ), + SharedMailboxes: onDemand( + () => import('@/features/lock/SharedMailboxesPage'), + (m) => m.SharedMailboxesPage, + ), + Deliverability: onDemand( + () => import('@/features/deliverability/DeliverabilityPage'), + (m) => m.DeliverabilityPage, + ), + ScheduledReports: onDemand( + () => import('@/features/scheduledReports/ScheduledReportsPage'), + (m) => m.ScheduledReportsPage, + ), + LegalHolds: onDemand( + () => import('@/features/hold/LegalHoldsPage'), + (m) => m.LegalHoldsPage, + ), + ComplianceOverview: onDemand( + () => import('@/features/compliance/ComplianceOverviewPage'), + (m) => m.ComplianceOverviewPage, + ), + DlpRules: onDemand( + () => import('@/features/dlp/RulesPage'), + (m) => m.DlpRulesPage, + ), + MailFlowRules: onDemand( + () => import('@/features/dlp/RulesPage'), + (m) => m.MailFlowRulesPage, + ), + Journal: onDemand( + () => import('@/features/journal/JournalPage'), + (m) => m.JournalPage, + ), + HeldMail: onDemand( + () => import('@/features/dlp/HeldMailPage'), + (m) => m.HeldMailPage, + ), + DataInventory: onDemand( + () => import('@/features/compliance/DataInventoryPage'), + (m) => m.DataInventoryPage, + ), +}; + +/** Guided jobs, by the name after `Wizard/`; always reached by choosing "Guide me", never by default. */ +const WIZARDS: Record = { + limits: onDemand( + () => import('@/features/limits/LimitsSetupPage'), + (m) => m.LimitsSetupPage, + ), + directory: onDemand( + () => import('@/features/directory/DirectorySetupPage'), + (m) => m.DirectorySetupPage, + ), + sending: onDemand( + () => import('@/features/sending/SendingSetupPage'), + (m) => m.SendingSetupPage, + ), + certificates: onDemand( + () => import('@/features/certificates/CertificateSetupPage'), + (m) => m.CertificateSetupPage, + ), +}; + +const stacked = (...parts: ReactNode[]) => createElement('div', { className: 'space-y-4' }, ...parts); +const settingsForm = (viewName: string) => ; +const reportsPage = (viewName: string) => ; + +/** Settings pages that open with something above, or in place of, the plain form. */ +const SINGLETON_PAGES: Record ReactNode> = { + 'x:Authentication': (v) => stacked(, settingsForm(v)), + // Sender checks open with a choice of level. + 'x:SenderAuth': (v) => , + // Every retention page opens with all the "how long"s as sentences. + 'x:DataRetention': (v) => , + // Every report page opens with what goes out and comes in. + 'x:ReportSettings': reportsPage, + 'x:DmarcReportSettings': reportsPage, + 'x:TlsReportSettings': reportsPage, + 'x:DkimReportSettings': reportsPage, + 'x:SpfReportSettings': reportsPage, + 'x:DsnReportSettings': reportsPage, + // The metrics pages open with where the numbers go. + 'x:Metrics': (v) => , + // Spam filter › General opens with how hard the filter is. + 'x:SpamSettings': (v) => , + 'x:Sharing': (v) => stacked(, settingsForm(v)), + 'x:Security': (v) => stacked(, settingsForm(v)), + 'x:MtaOutboundStrategy': (v) => stacked(, settingsForm(v)), +}; + +const listUnder = (above: ReactNode) => (viewName: string) => stacked(above, ); +const limitsList = (object: LimitObject) => () => + stacked(, ); + +/** Lists that open with a guide or a summary above them, or have a page of their own. */ +const LIST_PAGES: Record ReactNode> = { + 'x:Certificate': listUnder(), + 'x:AcmeProvider': listUnder(), + // Received reports open with who sends as us and whether they pass. + 'x:DmarcExternalReport': listUnder(), + 'x:TlsExternalReport': listUnder(), + 'x:MtaRoute': listUnder(), + 'x:Directory': listUnder(), + 'x:MtaInboundThrottle': limitsList('x:MtaInboundThrottle'), + 'x:MtaOutboundThrottle': limitsList('x:MtaOutboundThrottle'), + 'x:MtaQueueQuota': limitsList('x:MtaQueueQuota'), + 'x:Alert': (v) => , + 'x:NetworkListener': (v) => , + 'x:WebHook': (v) => , +}; + +const unknown = (text: string) => ( +

{text}
+); + +function recordPage(viewName: string, objectName: string, permissionPrefix: string, id: string): ReactNode { + if (objectName === 'x:Trace') return ; + const page = useAccountStore.getState().hasObjectPermission(permissionPrefix, 'Update') ? ( + : undefined} + /> + ) : ( + + ); + // A tenant administrator reads their tenant without changing it, yet may + // still turn its legacy-protocols switch, so it shows on the read-only page too. + if (objectName === 'x:Tenant') { + return stacked(, , page); + } + // A person's page says whether they're on hold or locked. + if (viewName === 'x:Account/User') { + return stacked(, , page); + } + return page; +} + +function pageFor( + schema: Schema | null, + viewName: string | undefined, + id: string | undefined, + section: string | undefined, +): ReactNode { + const route = pageRoute(schema, viewName, id); + const view = viewName ?? ''; + switch (route.kind) { + case 'none': + return ; + case 'dashboard': + return ; + case 'wizard': { + if (route.name === 'dns' && route.param) return ; + const Wizard = WIZARDS[route.name]; + return Wizard ? : unknown(`Unknown guide: ${route.name}`); + } + case 'custom': { + const Page = CUSTOM_PAGES[route.name]; + return Page ? : unknown(`Unknown component: ${route.name}`); + } + case 'schema-loading': + return
Loading...
; + case 'unknown': + return
Unknown view: {view}
; + case 'action': + return ; + case 'singleton': + return (SINGLETON_PAGES[route.objectName] ?? settingsForm)(view); + case 'create': + return ; + case 'record': + return recordPage(view, route.objectName, route.permissionPrefix, route.id); + case 'list': + return LIST_PAGES[view]?.(view) ?? ; + } +} + interface MainContentProps { viewName?: string; id?: string; section?: string; } +/** The page an address names, under the shell. Lists are fetched fresh on every page change. */ export function MainContent({ viewName, id, section }: MainContentProps) { const schema = useSchemaStore((s) => s.schema); const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists); @@ -183,288 +294,5 @@ export function MainContent({ viewName, id, section }: MainContentProps) { invalidateAllObjectLists(); }, [viewName, invalidateAllObjectLists]); - return }>{renderView(schema, viewName, id, section)}; -} - -function renderView(schema: Schema | null, viewName?: string, id?: string, section?: string): ReactNode { - if (!viewName) { - return ; - } - - if (viewName.startsWith('Dashboard/')) { - const dashboardId = viewName.slice('Dashboard/'.length); - return ; - } - - // INBUXA: guided jobs. Always reached by choosing "Guide me", never by default. - if (viewName.startsWith('Wizard/')) { - const [, wizard, param] = viewName.split('/'); - if (wizard === 'dns' && param) { - return ; - } - // inbuxa: guided setup, sending and receiving limits (settings-reorg). - if (wizard === 'limits') { - return ; - } - // inbuxa: guided setup, connect a sign-in directory (settings-reorg). - if (wizard === 'directory') { - return ; - } - // inbuxa: guided setup, how this server sends mail (settings-reorg). - if (wizard === 'sending') { - return ; - } - // inbuxa: guided setup, automatic certificates (settings-reorg). - if (wizard === 'certificates') { - return ; - } - return ( -
- Unknown guide: {wizard} -
- ); - } - - if (viewName.startsWith('CustomComponent/')) { - const componentName = viewName.slice('CustomComponent/'.length); - if (componentName === 'Dashboard') { - const firstId = schema?.dashboards?.[0]?.id ?? 'overview'; - return ; - } - if (componentName === 'LiveDelivery') { - return ; - } - if (componentName === 'LiveTracing') { - return ; - } - // INBUXA: Settings › Security › Hardening (legacy-protocols spec). - // inbuxa: the security to-do list, which holds the legacy-protocols switch - if (componentName === 'LegacyProtocols') { - return ; - } - // inbuxa: Settings › Overview (settings-reorg). - if (componentName === 'SettingsOverview') { - return ; - } - if (componentName === 'LocalAi') { - return ; - } - if (componentName === 'AuditLog') { - return ; - } - if (componentName === 'AccountLocks') { - return ; - } - if (componentName === 'SharedMailboxes') { - return ; - } - if (componentName === 'Deliverability') { - return ; - } - if (componentName === 'ScheduledReports') { - return ; - } - if (componentName === 'LegalHolds') { - return ; - } - if (componentName === 'ComplianceOverview') { - return ; - } - if (componentName === 'DlpRules') { - return ; - } - if (componentName === 'MailFlowRules') { - return ; - } - if (componentName === 'Journal') { - return ; - } - if (componentName === 'HeldMail') { - return ; - } - if (componentName === 'DataInventory') { - return ; - } - return ( -
- Unknown component: {componentName} -
- ); - } - - if (!schema) { - return
Loading...
; - } - - const resolved = resolveObject(schema, viewName); - if (!resolved) { - return
Unknown view: {viewName}
; - } - - if (resolved.objectName === 'x:Action') { - return ; - } - - if (resolved.objectType.type === 'singleton') { - // INBUXA: the Security settings carry the legacy protocols banner (LP-18). - if (resolved.objectName === 'x:Authentication') { - return ( -
- - -
- ); - } - // inbuxa: Sender checks open with a choice of level (settings-reorg). - if (resolved.objectName === 'x:SenderAuth') { - return ; - } - // inbuxa: every retention page opens with all the "how long"s as sentences (settings-reorg). - if (resolved.objectName === 'x:DataRetention') { - return ; - } - // inbuxa: every report page opens with what goes out and comes in (settings-reorg). - if ( - [ - 'x:ReportSettings', - 'x:DmarcReportSettings', - 'x:TlsReportSettings', - 'x:DkimReportSettings', - 'x:SpfReportSettings', - 'x:DsnReportSettings', - ].includes(resolved.objectName) - ) { - return ; - } - // inbuxa: the metrics pages open with where the numbers go (settings-reorg). - if (resolved.objectName === 'x:Metrics') { - return ; - } - // inbuxa: Spam filter › General opens with how hard the filter is (settings-reorg). - if (resolved.objectName === 'x:SpamSettings') { - return ; - } - // inbuxa: Sharing opens with who may share (MA-C) - if (resolved.objectName === 'x:Sharing') { - return ( -
- - -
- ); - } - if (resolved.objectName === 'x:Security') { - return ( -
- - -
- ); - } - // inbuxa: the sending guide, offered where outbound delivery is set (settings-reorg). - if (resolved.objectName === 'x:MtaOutboundStrategy') { - return ( -
- - -
- ); - } - return ; - } - - if (id === 'new') { - return ; - } - - if (id) { - if (resolved.objectName === 'x:Trace') { - return ; - } - const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update'); - const page = canUpdate ? ( - : undefined} - /> - ) : ( - - ); - // INBUXA: a tenant's page carries its legacy protocols switch (LP-9). A - // tenant admin reads its tenant without changing it (MT-12), and may - // still turn the switch, so it shows on the read-only page too. - if (resolved.objectName === 'x:Tenant') { - return ( -
- - - {page} -
- ); - } - // INBUXA: a person's page says whether it is locked (AL-1) - if (viewName === 'x:Account/User') { - return ( -
- - - {page} -
- ); - } - return page; - } - - if (viewName === 'x:Certificate' || viewName === 'x:AcmeProvider') { - return ( -
- - -
- ); - } - // inbuxa: received reports open with who sends as us and whether they pass (admin UX roadmap, item 6). - if (viewName === 'x:DmarcExternalReport' || viewName === 'x:TlsExternalReport') { - return ( -
- {viewName === 'x:DmarcExternalReport' ? : } - -
- ); - } - if (viewName === 'x:MtaRoute') { - return ( -
- - -
- ); - } - if (viewName === 'x:Directory') { - return ( -
- - -
- ); - } - if (viewName === 'x:MtaInboundThrottle' || viewName === 'x:MtaOutboundThrottle' || viewName === 'x:MtaQueueQuota') { - return ( -
- - -
- ); - } - if (viewName === 'x:Alert') { - return ; - } - if (viewName === 'x:NetworkListener') { - return ; - } - if (viewName === 'x:WebHook') { - return ; - } - return ; + return }>{pageFor(schema, viewName, id, section)}; } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 6eb2cdd..04776e8 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,20 +1,16 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; -import * as LucideIcons from 'lucide-react'; -const { ChevronDown, Lock, PanelLeftClose, PanelLeftOpen } = LucideIcons; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; +import { ChevronDown, Lock, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; + import { IconTile } from '@/components/common/IconTile'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { DropdownMenu, DropdownMenuContent, @@ -22,10 +18,7 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { useUIStore } from '@/stores/uiStore'; -import { useAccountStore } from '@/stores/accountStore'; -import { useSchemaStore } from '@/stores/schemaStore'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { visibleLayouts } from '@/lib/layout'; import { checkIsEnterprise, @@ -34,21 +27,41 @@ import { resolveViewPath, subtreeContainsActive, subtreeHasVisibleLink, + topItemKey, visibleLinks, } from '@/lib/navTree'; -import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; +import { cn } from '@/lib/utils'; +import { useAccountStore } from '@/stores/accountStore'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { useUIStore } from '@/stores/uiStore'; +import type { LayoutItem, LayoutSubItem } from '@/types/schema'; -interface AutoOpenCollapsibleProps { - containsActive: boolean; - children: React.ReactNode; +interface NavPlace { + section: string; + path: string; + edition: string; + go: (path: string) => void; + /** Given to the active link's button, so it can be scrolled into view. */ + activeRef: (el: HTMLButtonElement | null) => void; } -function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) { - const [open, setOpen] = useState(containsActive); - const [prevContainsActive, setPrevContainsActive] = useState(containsActive); - if (containsActive !== prevContainsActive) { - setPrevContainsActive(containsActive); - if (containsActive) setOpen(true); +const Place = createContext(null); +const usePlace = () => useContext(Place)!; + +/** How a link shows: not at all, locked (an enterprise page on the community edition), or open. */ +function linkState(viewName: string, edition: string): 'hidden' | 'locked' | 'open' { + if (!checkLinkVisible(viewName)) return 'hidden'; + if (!checkIsEnterprise(viewName)) return 'open'; + return edition === 'oss' ? 'hidden' : edition === 'community' ? 'locked' : 'open'; +} + +/** A group that opens by itself when the page it holds becomes the active one. */ +function Group({ holdsActive, children }: { holdsActive: boolean; children: ReactNode }) { + const [open, setOpen] = useState(holdsActive); + const [lastHoldsActive, setLastHoldsActive] = useState(holdsActive); + if (holdsActive !== lastHoldsActive) { + setLastHoldsActive(holdsActive); + if (holdsActive) setOpen(true); } return ( @@ -57,206 +70,125 @@ function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsiblePr ); } -type ActiveItemRef = (el: HTMLButtonElement | null) => void; +const indent = (depth: number) => (depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined); +const subKey = (item: LayoutSubItem) => (item.type === 'link' ? item.viewName : item.name); +const CHEVRON = 'shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]'; -interface SidebarSubItemProps { - item: LayoutSubItem; - depth: number; - sectionName: string; - currentPath: string; - navigate: ReturnType; - edition: string; - activeItemRef: ActiveItemRef; -} +function SubItem({ item, depth }: { item: LayoutSubItem; depth: number }) { + const { section, path, edition, go, activeRef } = usePlace(); -function SidebarSubItem({ - item, - depth, - sectionName, - currentPath, - navigate, - edition, - activeItemRef, -}: SidebarSubItemProps) { if (item.type === 'link') { - if (!checkLinkVisible(item.viewName)) return null; - - const path = resolveViewPath(sectionName, item.viewName); - const isActive = pathMatchesView(currentPath, sectionName, item.viewName); - const enterprise = checkIsEnterprise(item.viewName); - const isLocked = enterprise && edition === 'community'; - const isHidden = enterprise && edition === 'oss'; - - if (isHidden) return null; - + const state = linkState(item.viewName, edition); + if (state === 'hidden') return null; + const active = pathMatchesView(path, section, item.viewName); return ( ); } - if (item.type === 'container') { - if (!subtreeHasVisibleLink(item.items, edition)) return null; - - const containsActive = subtreeContainsActive(item.items, currentPath, sectionName); - return ( - - - - - - {item.items.map((sub) => ( - - ))} - - - ); - } - - return null; + if (!subtreeHasVisibleLink(item.items, edition)) return null; + return ( + + + + + + {item.items.map((sub) => ( + + ))} + + + ); } -interface SidebarTopItemProps { - item: LayoutItem; - sectionName: string; - currentPath: string; - navigate: ReturnType; - edition: string; - activeItemRef: ActiveItemRef; -} +const TOP = + 'h-10 w-full justify-start gap-3 rounded-xl px-2 font-medium text-foreground/85 hover:bg-muted hover:text-foreground'; + +function TopItem({ item }: { item: LayoutItem }) { + const { section, path, edition, go, activeRef } = usePlace(); -function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, activeItemRef }: SidebarTopItemProps) { if ('link' in item) { const { name, icon, viewName } = item.link; - - if (!checkLinkVisible(viewName)) return null; - - const path = resolveViewPath(sectionName, viewName); - const isActive = pathMatchesView(currentPath, sectionName, viewName); - const enterprise = checkIsEnterprise(viewName); - const isLocked = enterprise && edition === 'community'; - const isHidden = enterprise && edition === 'oss'; - - if (isHidden) return null; - + const state = linkState(viewName, edition); + if (state === 'hidden') return null; + const active = pathMatchesView(path, section, viewName); return ( ); } - if ('container' in item) { - const { name, icon, items } = item.container; - if (!subtreeHasVisibleLink(items, edition)) return null; - - const containsActive = subtreeContainsActive(items, currentPath, sectionName); - - return ( - - - - - - {items.map((sub) => ( - - ))} - - - ); - } - - return null; + const { name, icon, items } = item.container; + if (!subtreeHasVisibleLink(items, edition)) return null; + const holdsActive = subtreeContainsActive(items, path, section); + return ( + + + + + + {items.map((sub) => ( + + ))} + + + ); } -/** INBUXA: one entry of the collapsed sidebar: its tile, a label on hover, a menu for a group. */ -function RailItem({ - item, - sectionName, - currentPath, - navigate, - edition, -}: { - item: LayoutItem; - sectionName: string; - currentPath: string; - navigate: ReturnType; - edition: string; -}) { - const base = 'mx-auto flex h-11 w-11 items-center justify-center rounded-xl transition-colors hover:bg-muted'; +const RAIL = 'mx-auto flex h-11 w-11 items-center justify-center rounded-xl transition-colors hover:bg-muted'; + +/** One entry of the folded sidebar: its tile, its name on hover, and a menu for a group. */ +function RailItem({ item }: { item: LayoutItem }) { + const { section, path, edition, go } = usePlace(); + if ('link' in item) { const { name, icon, viewName } = item.link; if (!checkLinkVisible(viewName)) return null; - const isActive = pathMatchesView(currentPath, sectionName, viewName); + const active = pathMatchesView(path, section, viewName); return ( @@ -265,16 +197,19 @@ function RailItem({ ); } + const { name, icon, items } = item.container; if (!subtreeHasVisibleLink(items, edition)) return null; - const links = visibleLinks(items, edition); - const containsActive = subtreeContainsActive(items, currentPath, sectionName); return ( - @@ -286,13 +221,13 @@ function RailItem({ {name} - {links.map((l) => ( + {visibleLinks(items, edition).map((link) => ( navigate(resolveViewPath(sectionName, l.viewName))} + key={link.viewName} + className={cn(pathMatchesView(path, section, link.viewName) && 'bg-accent text-accent-foreground')} + onClick={() => go(resolveViewPath(section, link.viewName))} > - {l.name} + {link.name} ))} @@ -300,18 +235,22 @@ function RailItem({ ); } +const isPhone = () => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches; +const isWide = () => typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches; + interface SidebarProps { /** - * INBUXA: in the modern shell the section bar does the navigating on a wide - * screen, but a phone has no room for it — the sidebar stays as the - * slide-over behind the hamburger, and nothing else. + * In the modern shell the section bar navigates on a wide screen, but a + * phone has no room for it: the sidebar stays only as the slide-over + * behind the menu button. */ mobileOnly?: boolean; } +/** The current section's pages: a slide-over on a phone, a column or a folded rail on a wide screen. */ export function Sidebar({ mobileOnly = false }: SidebarProps = {}) { const navigate = useNavigate(); - const location = useLocation(); + const { pathname } = useLocation(); const activeSection = useUIStore((s) => s.activeSection); const sidebarOpen = useUIStore((s) => s.sidebarOpen); const setSidebarOpen = useUIStore((s) => s.setSidebarOpen); @@ -321,76 +260,66 @@ export function Sidebar({ mobileOnly = false }: SidebarProps = {}) { const edition = useAccountStore((s) => s.edition); const permissions = useAccountStore((s) => s.permissions); const hasPermission = useAccountStore((s) => s.hasPermission); + const activeItem = useRef(null); - const activeItemRef = useCallback((el) => { + const activeRef = useCallback((el: HTMLButtonElement | null) => { activeItem.current = el; }, []); - const layouts = useMemo(() => { - if (!schema) return []; + const layout = useMemo(() => { + if (!schema) return undefined; const canGet = (prefix: string) => permissions.includes(`${prefix}Get`); - return visibleLayouts(schema, edition, canGet, hasPermission); - }, [schema, edition, permissions, hasPermission]); + return visibleLayouts(schema, edition, canGet, hasPermission).find((l) => l.name === activeSection); + }, [schema, edition, permissions, hasPermission, activeSection]); + // On a phone, moving to a page closes the slide-over. useEffect(() => { - if (typeof window === 'undefined') return; - if (window.matchMedia('(max-width: 767px)').matches) { - setSidebarOpen(false); - } - }, [location.pathname, setSidebarOpen]); + if (isPhone()) setSidebarOpen(false); + }, [pathname, setSidebarOpen]); useEffect(() => { activeItem.current?.scrollIntoView({ block: 'nearest' }); - }, [location.pathname, activeSection]); + }, [pathname, activeSection]); - if (!sidebarOpen || !schema) return null; + if (!sidebarOpen || !schema || !layout) return null; - const layout: Layout | undefined = layouts.find((l) => l.name === activeSection); - if (!layout) return null; + const place: NavPlace = { section: layout.name, path: pathname, edition, go: navigate, activeRef }; // Folding to a rail is for wide screens; a phone keeps the slide-over, and so // does the modern shell, where the rail would sit under the section bar. - const collapsed = - !mobileOnly && sidebarCollapsed && typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches; - - if (collapsed) { + if (!mobileOnly && sidebarCollapsed && isWide()) { return ( - - - + + + + + ); } return ( - <> + - + ); } diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 51bd495..f20080f 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -1,21 +1,30 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ +import { createElement, useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import * as LucideIcons from 'lucide-react'; -const { Sun, Moon, User, LogOut, Check, Menu, Search, FileCode, Palette, LayoutTemplate, Accessibility, Keyboard } = - LucideIcons; -import { Button } from '@/components/ui/button'; +import { + Accessibility, + Check, + FileCode, + Keyboard, + LayoutTemplate, + LogOut, + Menu, + Moon, + Palette, + Search, + Sun, + User, +} from 'lucide-react'; + import { CommandPalette } from '@/components/common/CommandPalette'; -import { OPEN_COMMAND_BAR, OPEN_SHORTCUTS } from '@/lib/shortcuts'; -import { isContrastPref, isMotionPref } from '@/lib/a11yPrefs'; +import Logo from '@/components/common/Logo'; +import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, @@ -30,35 +39,91 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { isPaletteId, PALETTES } from '@/lib/palettes'; -import Logo from '@/components/common/Logo'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { SOURCE_URL } from '@/lib/sourceDownload'; -import { visibleLayouts } from '@/lib/layout'; +import { isContrastPref, isMotionPref } from '@/lib/a11yPrefs'; +import { iconByName } from '@/lib/iconByName'; import { sectionLandingLink } from '@/lib/lastVisited'; +import { visibleLayouts } from '@/lib/layout'; +import { isPaletteId, PALETTES } from '@/lib/palettes'; +import { isCommandBarKey, OPEN_COMMAND_BAR, OPEN_SHORTCUTS } from '@/lib/shortcuts'; +import { SOURCE_URL } from '@/lib/sourceDownload'; import { cn } from '@/lib/utils'; -import { isAdminLayout, useUIStore } from '@/stores/uiStore'; -import { useAuthStore } from '@/stores/authStore'; import { signOut } from '@/services/auth/signOut'; -import { createElement, useEffect, useState } from 'react'; import { useAccountStore } from '@/stores/accountStore'; +import { useAuthStore } from '@/stores/authStore'; import { useSchemaStore } from '@/stores/schemaStore'; +import { isAdminLayout, useUIStore } from '@/stores/uiStore'; +import type { Layout } from '@/types/schema'; const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); -function getIcon(name: string): LucideIcons.LucideIcon { - const formatted = name - .split('-') - .map((s) => s[0].toUpperCase() + s.slice(1)) - .join(''); - return ((LucideIcons as Record)[formatted] as LucideIcons.LucideIcon) || LucideIcons.Circle; +/** The layouts this account may visit, and a way to open one where it was last left. */ +function useSections() { + const navigate = useNavigate(); + const setActiveSection = useUIStore((s) => s.setActiveSection); + const edition = useAccountStore((s) => s.edition); + const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); + const hasPermission = useAccountStore((s) => s.hasPermission); + const schema = useSchemaStore((s) => s.schema); + const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get'); + + const sections = schema ? visibleLayouts(schema, edition, canGet, hasPermission) : []; + const open = (layout: Layout) => { + setActiveSection(layout.name); + const link = schema && sectionLandingLink(schema, layout, edition, canGet, hasPermission); + if (link) navigate(`/${layout.name}/${link}`); + }; + return { sections, open }; } -export function TopBar() { +/** The sections as tabs, one click away where the eye already looks. */ +function SectionTabs() { + const { t } = useTranslation(); + const activeSection = useUIStore((s) => s.activeSection); + const { sections, open } = useSections(); + if (sections.length <= 1) return null; + + return ( + +
+ {sections.map((layout) => { + const active = layout.name === activeSection; + return ( + + + + + {layout.name} + + ); + })} +
+
+ ); +} + +/** Sections, accounts, the reader's display choices, the source and signing out. */ +function AccountMenu() { const { t } = useTranslation(); const navigate = useNavigate(); const theme = useUIStore((s) => s.theme); - const toggleTheme = useUIStore((s) => s.toggleTheme); const palette = useUIStore((s) => s.palette); const setPalette = useUIStore((s) => s.setPalette); const adminLayout = useUIStore((s) => s.adminLayout); @@ -67,39 +132,171 @@ export function TopBar() { const setContrast = useUIStore((s) => s.setContrast); const motion = useUIStore((s) => s.motion); const setMotion = useUIStore((s) => s.setMotion); - const toggleSidebar = useUIStore((s) => s.toggleSidebar); - const setActiveSection = useUIStore((s) => s.setActiveSection); - const activeSection = useUIStore((s) => s.activeSection); const accounts = useAuthStore((s) => s.accounts); const activeAccountId = useAuthStore((s) => s.activeAccountId); const switchAccount = useAuthStore((s) => s.switchAccount); - const edition = useAccountStore((s) => s.edition); - const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); - const hasPermission = useAccountStore((s) => s.hasPermission); - const schema = useSchemaStore((s) => s.schema); + const { sections, open } = useSections(); + const followSystem = t('a11y.followSystem', 'Follow the system'); + + return ( + + + + + + {sections.length > 0 && ( + <> + {t('sections', 'Sections')} + + {sections.map((layout) => { + const Icon = iconByName(layout.icon); + return ( + open(layout)}> + + {layout.name} + + ); + })} + + + + )} + + {Object.keys(accounts).length > 0 && ( + <> + {t('accounts', 'Accounts')} + + {Object.entries(accounts).map(([id, info]) => ( + switchAccount(id)}> + {id === activeAccountId && } + {info.name || id} + + ))} + + + + )} + + + + + {t('nav.layoutMenu', 'Layout')} + + + isAdminLayout(v) && setAdminLayout(v)}> + + {t('nav.layoutModern', 'Modern')} + + + {t('nav.layoutLegacy', 'Legacy')} + + + +

+ {adminLayout === 'modern' + ? t('nav.layoutModernHint', 'Sections across the top; the page gets the full width.') + : t('nav.layoutLegacyHint', 'The sidebar, as the old web UI had it.')} +

+
+
+ + + + + {t('theme.menu', 'Theme')} + + + isPaletteId(v) && setPalette(v)}> + {PALETTES.map((p) => ( + + + ))} + + + + + + + + {t('a11y.menu', 'Accessibility')} + + + {t('a11y.contrast', 'Contrast')} + isContrastPref(v) && setContrast(v)}> + {followSystem} + {t('a11y.contrastStandard', 'Standard')} + {t('a11y.contrastHigh', 'High')} + + + {t('a11y.motion', 'Motion')} + isMotionPref(v) && setMotion(v)}> + {followSystem} + {t('a11y.motionReduce', 'Reduce')} + {t('a11y.motionFull', 'Full')} + + + window.dispatchEvent(new Event(OPEN_SHORTCUTS))}> + + {t('a11y.shortcuts', 'Keyboard shortcuts')} + + + + + + + + + + {t('source.menu', 'Source code (AGPL-3.0)')} + + + + + signOut(navigate)}> + + {t('logout', 'Logout')} + +
+
+ ); +} + +/** The bar across the top: menu button on a phone, logo, the command bar, sections, theme and the account menu. */ +export function TopBar() { + const { t } = useTranslation(); + const theme = useUIStore((s) => s.theme); + const toggleTheme = useUIStore((s) => s.toggleTheme); + const toggleSidebar = useUIStore((s) => s.toggleSidebar); const [paletteOpen, setPaletteOpen] = useState(false); + // Ctrl+K / ⌘K toggles the command bar; `/` and other callers ask for it by event. useEffect(() => { - function handleGlobalKeyDown(e: KeyboardEvent) { - if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') { - e.preventDefault(); - setPaletteOpen((open) => !open); - } - } - // inbuxa: `/` asks for it too (keyboard shortcuts, roadmap item 13) + const onKey = (e: KeyboardEvent) => { + if (!isCommandBarKey(e)) return; + e.preventDefault(); + setPaletteOpen((open) => !open); + }; const open = () => setPaletteOpen(true); - document.addEventListener('keydown', handleGlobalKeyDown); + document.addEventListener('keydown', onKey); window.addEventListener(OPEN_COMMAND_BAR, open); return () => { - document.removeEventListener('keydown', handleGlobalKeyDown); + document.removeEventListener('keydown', onKey); window.removeEventListener(OPEN_COMMAND_BAR, open); }; }, []); - const navigableLayouts = schema - ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) - : []; - return (
- - {/* INBUXA: the three areas, one click away, where the eye already looks. */} - {schema && navigableLayouts.length > 1 && ( - -
- {navigableLayouts.map((layout) => { - const isActive = layout.name === activeSection; - return ( - - - - - {layout.name} - - ); - })} -
-
- )} + - - - - - - {schema && navigableLayouts.length > 0 && ( - <> - {t('sections', 'Sections')} - - {navigableLayouts.map((layout) => { - const Icon = getIcon(layout.icon); - return ( - { - setActiveSection(layout.name); - const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get'); - const firstLink = sectionLandingLink(schema, layout, edition, canGet, hasPermission); - if (firstLink) { - navigate(`/${layout.name}/${firstLink}`); - } - }} - > - - {layout.name} - - ); - })} - - - - )} - - {Object.keys(accounts).length > 0 && ( - <> - {t('accounts', 'Accounts')} - - {Object.entries(accounts).map(([id, info]) => ( - switchAccount(id)}> - {id === activeAccountId && } - {info.name || id} - - ))} - - - - )} - - {/* INBUXA: the shell is the reader's choice, the way the palette is. */} - - - - {t('nav.layoutMenu', 'Layout')} - - - isAdminLayout(v) && setAdminLayout(v)} - > - - {t('nav.layoutModern', 'Modern')} - - - {t('nav.layoutLegacy', 'Legacy')} - - - -

- {adminLayout === 'modern' - ? t('nav.layoutModernHint', 'Sections across the top; the page gets the full width.') - : t('nav.layoutLegacyHint', 'The sidebar, as the old web UI had it.')} -

-
-
- - {/* INBUXA: the same palettes as INBUXA webmail. */} - - - - {t('theme.menu', 'Theme')} - - - isPaletteId(v) && setPalette(v)}> - {PALETTES.map((p) => ( - - - ))} - - - - - {/* inbuxa: contrast and motion, over any theme (roadmap item 13) */} - - - - {t('a11y.menu', 'Accessibility')} - - - {t('a11y.contrast', 'Contrast')} - isContrastPref(v) && setContrast(v)}> - - {t('a11y.followSystem', 'Follow the system')} - - - {t('a11y.contrastStandard', 'Standard')} - - {t('a11y.contrastHigh', 'High')} - - - {t('a11y.motion', 'Motion')} - isMotionPref(v) && setMotion(v)}> - - {t('a11y.followSystem', 'Follow the system')} - - {t('a11y.motionReduce', 'Reduce')} - {t('a11y.motionFull', 'Full')} - - - window.dispatchEvent(new Event(OPEN_SHORTCUTS))}> - - {t('a11y.shortcuts', 'Keyboard shortcuts')} - - - - - - - - - - {t('source.menu', 'Source code (AGPL-3.0)')} - - - - - signOut(navigate)}> - - {t('logout', 'Logout')} - -
-
+
); diff --git a/src/components/layout/pageRoute.test.ts b/src/components/layout/pageRoute.test.ts new file mode 100644 index 0000000..4f2ecbb --- /dev/null +++ b/src/components/layout/pageRoute.test.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { pageRoute } from './pageRoute'; +import type { Schema } from '@/types/schema'; + +const schema = { + objects: { + 'x:Domain': { type: 'object', description: '', permissionPrefix: 'sysDomain' }, + 'x:Account': { type: 'object', description: '', permissionPrefix: 'sysAccount' }, + 'x:Account/User': { type: 'view', objectName: 'x:Account' }, + 'x:Security': { type: 'singleton', description: '', permissionPrefix: 'sysSecurity' }, + 'x:Action': { type: 'object', description: '', permissionPrefix: 'sysAction' }, + 'x:Action/Server': { type: 'view', objectName: 'x:Action' }, + }, + dashboards: [{ id: 'delivery', label: 'Delivery' }], +} as unknown as Schema; + +describe('pageRoute', () => { + it('has nothing to show without a view', () => { + expect(pageRoute(schema, undefined, undefined)).toEqual({ kind: 'none' }); + }); + + it('reads dashboards, the dashboard link opening the first one', () => { + expect(pageRoute(schema, 'Dashboard/overview', undefined)).toEqual({ kind: 'dashboard', dashboardId: 'overview' }); + expect(pageRoute(schema, 'CustomComponent/Dashboard', undefined)).toEqual({ + kind: 'dashboard', + dashboardId: 'delivery', + }); + expect(pageRoute(null, 'CustomComponent/Dashboard', undefined)).toEqual({ + kind: 'dashboard', + dashboardId: 'overview', + }); + }); + + it('reads guided jobs and the console’s own pages, schema or not', () => { + expect(pageRoute(null, 'Wizard/dns/b', undefined)).toEqual({ kind: 'wizard', name: 'dns', param: 'b' }); + expect(pageRoute(null, 'Wizard/limits', undefined)).toEqual({ kind: 'wizard', name: 'limits', param: undefined }); + expect(pageRoute(null, 'CustomComponent/AuditLog', undefined)).toEqual({ kind: 'custom', name: 'AuditLog' }); + }); + + it('waits for the schema for anything else, and knows when a view is unknown', () => { + expect(pageRoute(null, 'x:Domain', undefined)).toEqual({ kind: 'schema-loading' }); + expect(pageRoute(schema, 'x:Nope', undefined)).toEqual({ kind: 'unknown' }); + }); + + it('tells actions, singletons, new records, records and lists apart', () => { + expect(pageRoute(schema, 'x:Action/Server', undefined)).toEqual({ kind: 'action' }); + expect(pageRoute(schema, 'x:Action/Server', 'b')).toEqual({ kind: 'action' }); + expect(pageRoute(schema, 'x:Security', 'singleton')).toEqual({ kind: 'singleton', objectName: 'x:Security' }); + expect(pageRoute(schema, 'x:Security', undefined)).toEqual({ kind: 'singleton', objectName: 'x:Security' }); + expect(pageRoute(schema, 'x:Domain', 'new')).toEqual({ kind: 'create' }); + expect(pageRoute(schema, 'x:Account/User', 'b')).toEqual({ + kind: 'record', + objectName: 'x:Account', + permissionPrefix: 'sysAccount', + id: 'b', + }); + expect(pageRoute(schema, 'x:Domain', undefined)).toEqual({ kind: 'list' }); + }); +}); diff --git a/src/components/layout/pageRoute.ts b/src/components/layout/pageRoute.ts new file mode 100644 index 0000000..aad3938 --- /dev/null +++ b/src/components/layout/pageRoute.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** What kind of page an address is, before choosing the component that draws it. */ +import { resolveObject } from '@/lib/schemaResolver'; +import type { Schema } from '@/types/schema'; + +export type PageRoute = + | { kind: 'none' } + | { kind: 'dashboard'; dashboardId: string } + /** A guided job: `Wizard/[/]`. */ + | { kind: 'wizard'; name: string; param?: string } + /** One of the console's own pages: `CustomComponent/`. */ + | { kind: 'custom'; name: string } + | { kind: 'schema-loading' } + | { kind: 'unknown' } + | { kind: 'action' } + | { kind: 'singleton'; objectName: string } + | { kind: 'create' } + | { kind: 'record'; objectName: string; permissionPrefix: string; id: string } + | { kind: 'list' }; + +export function pageRoute(schema: Schema | null, viewName: string | undefined, id: string | undefined): PageRoute { + if (!viewName) return { kind: 'none' }; + if (viewName.startsWith('Dashboard/')) return { kind: 'dashboard', dashboardId: viewName.slice('Dashboard/'.length) }; + if (viewName === 'CustomComponent/Dashboard') { + // The dashboard link opens the schema's first dashboard. + return { kind: 'dashboard', dashboardId: schema?.dashboards?.[0]?.id ?? 'overview' }; + } + if (viewName.startsWith('Wizard/')) { + const [, name, param] = viewName.split('/'); + return { kind: 'wizard', name, param }; + } + if (viewName.startsWith('CustomComponent/')) + return { kind: 'custom', name: viewName.slice('CustomComponent/'.length) }; + + if (!schema) return { kind: 'schema-loading' }; + const resolved = resolveObject(schema, viewName); + if (!resolved) return { kind: 'unknown' }; + if (resolved.objectName === 'x:Action') return { kind: 'action' }; + if (resolved.objectType.type === 'singleton') return { kind: 'singleton', objectName: resolved.objectName }; + if (id === 'new') return { kind: 'create' }; + if (id) return { kind: 'record', objectName: resolved.objectName, permissionPrefix: resolved.permissionPrefix, id }; + return { kind: 'list' }; +} diff --git a/src/lib/iconByName.test.ts b/src/lib/iconByName.test.ts new file mode 100644 index 0000000..a16d271 --- /dev/null +++ b/src/lib/iconByName.test.ts @@ -0,0 +1,21 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { Circle, Server, SlidersHorizontal } from 'lucide-react'; +import { iconByName } from './iconByName'; + +describe('iconByName', () => { + it('finds an icon by its kebab-case name', () => { + expect(iconByName('server')).toBe(Server); + expect(iconByName('sliders-horizontal')).toBe(SlidersHorizontal); + }); + + it('falls back to a circle', () => { + expect(iconByName('no-such-icon')).toBe(Circle); + expect(iconByName('')).toBe(Circle); + }); +}); diff --git a/src/lib/iconByName.ts b/src/lib/iconByName.ts new file mode 100644 index 0000000..d91fa6d --- /dev/null +++ b/src/lib/iconByName.ts @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as LucideIcons from 'lucide-react'; + +/** The icon the schema names in kebab case (`sliders-horizontal`), or a plain circle for one it doesn't know. */ +export function iconByName(name: string): LucideIcons.LucideIcon { + const pascal = name + .split('-') + .map((s) => (s ? s[0].toUpperCase() + s.slice(1) : s)) + .join(''); + return ((LucideIcons as Record)[pascal] as LucideIcons.LucideIcon | undefined) ?? LucideIcons.Circle; +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index c662246..0b30d23 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -1,159 +1,99 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * Which navigation links an account sees and may open. Schema objects need + * read permission on themselves (or, for a view, on what it shows); the + * console's own pages follow the rules below. + */ import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema'; import { resolveObject } from '@/lib/schemaResolver'; import { SETTINGS_LAYOUT_NAME, SETTINGS_OVERVIEW_VIEW } from '@/lib/settingsLayout'; -function findFirstSubLink(items: LayoutSubItem[]): string | null { - for (const item of items) { - if (item.type === 'link') return item.viewName; - if (item.type === 'container') { - const found = findFirstSubLink(item.items); - if (found) return found; - } - } - return null; -} - -export function findFirstLinkInLayout(items: LayoutItem[] | Layout): string | null { - const list = Array.isArray(items) ? items : items.items; - for (const item of list) { - if ('link' in item) return item.link.viewName; - if ('container' in item) { - const found = findFirstSubLink(item.container.items); - if (found) return found; - } - } - return null; -} - export type CanGet = (permissionPrefix: string) => boolean; export type HasPermission = (permission: string) => boolean; -interface SpecialLinkInfo { - visible: boolean; - enterprise: boolean; +interface Checks { + edition: string; + canGet?: CanGet; + hasPerm?: HasPermission; } -function checkSpecialLink( - viewName: string, - edition: string, - hasPerm?: HasPermission, - canGet?: CanGet, -): SpecialLinkInfo | null { - if (viewName.startsWith('Dashboard/') || viewName === 'CustomComponent/Dashboard') { - if (edition === 'oss') return { visible: false, enterprise: true }; - const hasLiveMetrics = hasPerm ? hasPerm('liveMetrics') : true; - const hasTraceGet = canGet ? canGet('sysTrace') : true; - return { visible: hasLiveMetrics && hasTraceGet, enterprise: true }; - } +interface PageRule { + enterprise: boolean; + visible: (checks: Checks) => boolean; +} - if (viewName === 'CustomComponent/LiveDelivery') { - const allowed = hasPerm ? hasPerm('liveDeliveryTest') : true; - return { visible: allowed, enterprise: false }; - } +// Without a check to ask (as when only the enterprise flag is wanted), a rule passes. +const mayRead = + (...prefixes: string[]): PageRule['visible'] => + ({ canGet }) => + !canGet || prefixes.every(canGet); +const holds = (permission: string, { hasPerm }: Checks) => !hasPerm || hasPerm(permission); +const notOss = ({ edition }: Checks) => edition !== 'oss'; - if (viewName === 'CustomComponent/LiveTracing') { - if (edition === 'oss') return { visible: false, enterprise: true }; - const allowed = hasPerm ? hasPerm('liveTracing') : true; - return { visible: allowed, enterprise: true }; - } +const DASHBOARD: PageRule = { + enterprise: true, + visible: (c) => notOss(c) && holds('liveMetrics', c) && mayRead('sysTrace')(c), +}; - // INBUXA: the security to-do list and the legacy-protocols switch. Every - // check is server-wide, so it's for whoever may read the server's security - // settings, which tenant administrators can't (security to-do list spec). - if (viewName === 'CustomComponent/LegacyProtocols') { - return { visible: canGet ? canGet('sysSecurity') : true, enterprise: false }; - } +const readOnly = (...prefixes: string[]): PageRule => ({ enterprise: false, visible: mayRead(...prefixes) }); - // inbuxa: the Local AI page is for whoever may see the AI classifier. - if (viewName === 'CustomComponent/LocalAi') { - return { visible: canGet ? canGet('sysSpamLlm') : true, enterprise: false }; - } +/** The console's own pages, by view name. */ +const PAGES: Record = { + 'CustomComponent/Dashboard': DASHBOARD, + 'CustomComponent/LiveDelivery': { enterprise: false, visible: (c) => holds('liveDeliveryTest', c) }, + 'CustomComponent/LiveTracing': { enterprise: true, visible: (c) => notOss(c) && holds('liveTracing', c) }, + // Every check on it is server-wide, so tenant administrators don't see it. + 'CustomComponent/LegacyProtocols': readOnly('sysSecurity'), + 'CustomComponent/LocalAi': readOnly('sysSpamLlm'), + 'CustomComponent/AuditLog': readOnly('sysAudit'), + 'CustomComponent/LegalHolds': readOnly('sysLegalHold'), + 'CustomComponent/ComplianceOverview': readOnly('sysCompliance'), + 'CustomComponent/DataInventory': readOnly('sysCompliance'), + 'CustomComponent/DlpRules': readOnly('sysDlpPolicy'), + 'CustomComponent/Journal': readOnly('sysJournal'), + 'CustomComponent/HeldMail': readOnly('sysDlpReview'), + 'CustomComponent/MailFlowRules': readOnly('sysMailRule'), + 'CustomComponent/AccountLocks': readOnly('sysAccountLock'), + 'CustomComponent/SharedMailboxes': readOnly('sysAccountLock'), + 'CustomComponent/Deliverability': readOnly('sysDeliverability'), + 'CustomComponent/ScheduledReports': readOnly('sysScheduledReport'), + // Guided jobs are for whoever may read everything they change. + 'Wizard/certificates': readOnly('sysDomain', 'sysAcmeProvider', 'sysCertificate'), + 'Wizard/sending': readOnly('sysMtaRoute', 'sysMtaOutboundStrategy'), + 'Wizard/directory': readOnly('sysDirectory', 'sysDomain', 'sysAuthentication'), + 'Wizard/limits': readOnly('sysMtaInboundThrottle', 'sysMtaOutboundThrottle', 'sysMtaQueueQuota'), +}; - // inbuxa: the audit log, for whoever may read it (AU-9). - if (viewName === 'CustomComponent/AuditLog') { - return { visible: canGet ? canGet('sysAudit') : true, enterprise: false }; - } +/** + * A page this console can't draw is hidden: a server newer than the console + * may list pages it doesn't know yet, and counting them hid the first-boot + * wizard (inbuxa-server#135). + */ +const UNKNOWN_PAGE: PageRule = { enterprise: false, visible: () => false }; - // inbuxa: legal holds, for server administrators who may see them (LH-13). - if (viewName === 'CustomComponent/LegalHolds') { - return { visible: canGet ? canGet('sysLegalHold') : true, enterprise: false }; - } - // INBUXA: the compliance overview and data inventory (personal-data catalog) - if (viewName === 'CustomComponent/ComplianceOverview' || viewName === 'CustomComponent/DataInventory') { - return { visible: canGet ? canGet('sysCompliance') : true, enterprise: false }; - } +function pageRule(viewName: string): PageRule | null { + if (viewName.startsWith('Dashboard/')) return DASHBOARD; + if (viewName.startsWith('Wizard/dns/')) return readOnly('sysDomain'); + return PAGES[viewName] ?? (viewName.startsWith('CustomComponent/') ? UNKNOWN_PAGE : null); +} - // INBUXA: DLP and mail flow rules, and held mail (dlp-and-mail-flow-rules spec, §2.8) - if (viewName === 'CustomComponent/DlpRules') { - return { visible: canGet ? canGet('sysDlpPolicy') : true, enterprise: false }; - } - // INBUXA: journaling (journaling spec, §3) - if (viewName === 'CustomComponent/Journal') { - return { visible: canGet ? canGet('sysJournal') : true, enterprise: false }; - } - if (viewName === 'CustomComponent/HeldMail') { - return { visible: canGet ? canGet('sysDlpReview') : true, enterprise: false }; - } - if (viewName === 'CustomComponent/MailFlowRules') { - return { visible: canGet ? canGet('sysMailRule') : true, enterprise: false }; - } - - // inbuxa: locked accounts, for whoever may see them (AL-12). - if (viewName === 'CustomComponent/AccountLocks') { - return { visible: canGet ? canGet('sysAccountLock') : true, enterprise: false }; - } - // inbuxa: shared mailboxes are locks of their own kind (MA-S4) - if (viewName === 'CustomComponent/SharedMailboxes') { - return { visible: canGet ? canGet('sysAccountLock') : true, enterprise: false }; - } - // inbuxa: Domains › Deliverability, for whoever may read the reports (deliverability spec) - if (viewName === 'CustomComponent/Deliverability') { - return { visible: canGet ? canGet('sysDeliverability') : true, enterprise: false }; - } - // inbuxa: Reports › Scheduled, for whoever may see the reports (scheduled-reports spec) - if (viewName === 'CustomComponent/ScheduledReports') { - return { visible: canGet ? canGet('sysScheduledReport') : true, enterprise: false }; - } - - // INBUXA: a page this console can't draw is hidden. A server newer than the - // console lists pages the console doesn't know yet, and counting them as - // visible hid the first-boot wizard (inbuxa-server#135). - if (viewName.startsWith('CustomComponent/')) { - return { visible: false, enterprise: false }; - } - - // INBUXA: guided jobs are open to whoever may manage what they change. - if (viewName === 'Wizard/certificates') { - const ok = canGet ? canGet('sysDomain') && canGet('sysAcmeProvider') && canGet('sysCertificate') : true; - return { visible: ok, enterprise: false }; - } - if (viewName === 'Wizard/sending') { - const ok = canGet ? canGet('sysMtaRoute') && canGet('sysMtaOutboundStrategy') : true; - return { visible: ok, enterprise: false }; - } - if (viewName === 'Wizard/directory') { - const ok = canGet ? canGet('sysDirectory') && canGet('sysDomain') && canGet('sysAuthentication') : true; - return { visible: ok, enterprise: false }; - } - if (viewName === 'Wizard/limits') { - const ok = canGet - ? canGet('sysMtaInboundThrottle') && canGet('sysMtaOutboundThrottle') && canGet('sysMtaQueueQuota') - : true; - return { visible: ok, enterprise: false }; - } - if (viewName.startsWith('Wizard/dns/')) { - return { visible: canGet ? canGet('sysDomain') : true, enterprise: false }; +/** Every link's view name, top to bottom, going into containers as they come. */ +function* linksOf(items: (LayoutItem | LayoutSubItem)[]): Generator { + for (const item of items) { + if ('link' in item) yield item.link.viewName; + else if ('container' in item) yield* linksOf(item.container.items); + else if (item.type === 'link') yield item.viewName; + else yield* linksOf(item.items); } +} +function firstLink(items: (LayoutItem | LayoutSubItem)[], keep: (viewName: string) => boolean): string | null { + for (const viewName of linksOf(items)) if (keep(viewName)) return viewName; return null; } @@ -164,64 +104,44 @@ export function isLinkVisible( canGet: CanGet, hasPerm?: HasPermission, ): boolean { - // INBUXA: the Settings overview is the console's own page, there when any - // settings page is. Visible on its own, it made the Settings layout visible - // to the bootstrap administrator, and the wizard never showed. + // The Settings overview is there when any settings page is. Shown on its + // own, it made Settings visible to the bootstrap administrator, and the + // wizard never showed. if (viewName === SETTINGS_OVERVIEW_VIEW) { const settings = schema.layouts.find((layout) => layout.name === SETTINGS_LAYOUT_NAME); - return ( - settings?.items.some( - (item) => - 'container' in item && - findFirstVisibleSubLink(schema, item.container.items, edition, canGet, hasPerm) !== null, - ) ?? false - ); + const containers = (settings?.items ?? []).flatMap((item) => ('container' in item ? [item.container.items] : [])); + return containers.some((items) => firstLink(items, (v) => isLinkVisible(schema, v, edition, canGet, hasPerm))); } - const special = checkSpecialLink(viewName, edition, hasPerm, canGet); - if (special !== null) return special.visible; + const rule = pageRule(viewName); + if (rule) return rule.visible({ edition, canGet, hasPerm }); - const obj = schema.objects[viewName]; - if (!obj) return false; - const resolved = resolveObject(schema, viewName); - if (!resolved) return false; - - if (!canGet(resolved.permissionPrefix)) return false; - - if (resolved.enterprise && edition === 'oss') return false; - - return true; + const resolved = schema.objects[viewName] ? resolveObject(schema, viewName) : null; + if (!resolved || !canGet(resolved.permissionPrefix)) return false; + return !(resolved.enterprise && edition === 'oss'); } -export function isLinkEnterprise(schema: Schema, viewName: string, edition: string): boolean { - const special = checkSpecialLink(viewName, edition); - if (special !== null) return special.enterprise; - +export function isLinkEnterprise(schema: Schema, viewName: string): boolean { + const rule = pageRule(viewName); + if (rule) return rule.enterprise; const obj = schema.objects[viewName]; - if (!obj) return false; - if (obj.type === 'view') { - const parent = schema.objects[obj.objectName]; - return parent?.type !== 'view' && parent?.enterprise === true; - } - return obj.enterprise === true; + if (obj?.type !== 'view') return obj?.enterprise === true; + const parent = schema.objects[obj.objectName]; + return parent?.type !== 'view' && parent?.enterprise === true; } -function findFirstVisibleSubLink( +/** Visible, and not an enterprise page shown to the community edition only as a teaser. */ +export function isLinkAccessible( schema: Schema, - items: LayoutSubItem[], + viewName: string, edition: string, canGet: CanGet, hasPerm?: HasPermission, -): string | null { - for (const item of items) { - if (item.type === 'link') { - if (isLinkVisible(schema, item.viewName, edition, canGet, hasPerm)) return item.viewName; - } else if (item.type === 'container') { - const found = findFirstVisibleSubLink(schema, item.items, edition, canGet, hasPerm); - if (found) return found; - } - } - return null; +): boolean { + return ( + isLinkVisible(schema, viewName, edition, canGet, hasPerm) && + !(edition === 'community' && isLinkEnterprise(schema, viewName)) + ); } export function findFirstVisibleLinkInLayout( @@ -231,45 +151,7 @@ export function findFirstVisibleLinkInLayout( canGet: CanGet, hasPerm?: HasPermission, ): string | null { - for (const item of layout.items) { - if ('link' in item) { - if (isLinkVisible(schema, item.link.viewName, edition, canGet, hasPerm)) return item.link.viewName; - } else if ('container' in item) { - const found = findFirstVisibleSubLink(schema, item.container.items, edition, canGet, hasPerm); - if (found) return found; - } - } - return null; -} - -export function isLinkAccessible( - schema: Schema, - viewName: string, - edition: string, - canGet: CanGet, - hasPerm?: HasPermission, -): boolean { - if (!isLinkVisible(schema, viewName, edition, canGet, hasPerm)) return false; - if (edition === 'community' && isLinkEnterprise(schema, viewName, edition)) return false; - return true; -} - -function findFirstAccessibleSubLink( - schema: Schema, - items: LayoutSubItem[], - edition: string, - canGet: CanGet, - hasPerm?: HasPermission, -): string | null { - for (const item of items) { - if (item.type === 'link') { - if (isLinkAccessible(schema, item.viewName, edition, canGet, hasPerm)) return item.viewName; - } else if (item.type === 'container') { - const found = findFirstAccessibleSubLink(schema, item.items, edition, canGet, hasPerm); - if (found) return found; - } - } - return null; + return firstLink(layout.items, (v) => isLinkVisible(schema, v, edition, canGet, hasPerm)); } export function findFirstAccessibleLinkInLayout( @@ -279,19 +161,10 @@ export function findFirstAccessibleLinkInLayout( canGet: CanGet, hasPerm?: HasPermission, ): string | null { - for (const item of layout.items) { - if ('link' in item) { - if (isLinkAccessible(schema, item.link.viewName, edition, canGet, hasPerm)) return item.link.viewName; - } else if ('container' in item) { - const found = findFirstAccessibleSubLink(schema, item.container.items, edition, canGet, hasPerm); - if (found) return found; - } - } - return null; + return firstLink(layout.items, (v) => isLinkAccessible(schema, v, edition, canGet, hasPerm)); } +/** The layouts with at least one page the account may see. */ export function visibleLayouts(schema: Schema, edition: string, canGet: CanGet, hasPerm?: HasPermission): Layout[] { - return schema.layouts.filter( - (layout) => findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPerm) !== null, - ); + return schema.layouts.filter((layout) => findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPerm)); } diff --git a/src/lib/layoutRules.test.ts b/src/lib/layoutRules.test.ts new file mode 100644 index 0000000..add2873 --- /dev/null +++ b/src/lib/layoutRules.test.ts @@ -0,0 +1,186 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { + findFirstAccessibleLinkInLayout, + findFirstVisibleLinkInLayout, + isLinkAccessible, + isLinkEnterprise, + isLinkVisible, + visibleLayouts, +} from './layout'; +import type { Layout, Schema } from '@/types/schema'; + +const schema = { + objects: { + 'x:Domain': { type: 'object', description: '', permissionPrefix: 'sysDomain' }, + 'x:Tenant': { type: 'object', description: '', permissionPrefix: 'sysTenant', enterprise: true }, + 'x:Account': { type: 'object', description: '', permissionPrefix: 'sysAccount' }, + 'x:Account/User': { type: 'view', objectName: 'x:Account' }, + 'x:Tenant/Active': { type: 'view', objectName: 'x:Tenant' }, + 'x:Odd/View': { type: 'view', objectName: 'x:Missing' }, + }, + schemas: {}, + fields: {}, + forms: {}, + lists: {}, + enums: {}, + dashboards: [], + layouts: [], +} as unknown as Schema; + +const canGetFrom = (prefixes: string[]) => (prefix: string) => prefixes.includes(prefix); +const hasPermFrom = (perms: string[]) => (perm: string) => perms.includes(perm); +const all = () => true; +const none = () => false; + +describe('isLinkVisible for schema objects', () => { + it('needs read permission on the object, or on the object a view shows', () => { + expect(isLinkVisible(schema, 'x:Domain', 'enterprise', canGetFrom(['sysDomain']))).toBe(true); + expect(isLinkVisible(schema, 'x:Domain', 'enterprise', canGetFrom(['sysAccount']))).toBe(false); + expect(isLinkVisible(schema, 'x:Account/User', 'enterprise', canGetFrom(['sysAccount']))).toBe(true); + expect(isLinkVisible(schema, 'x:Account/User', 'enterprise', none)).toBe(false); + }); + + it('hides unknown objects, and enterprise ones on the open-source edition', () => { + expect(isLinkVisible(schema, 'x:Nope', 'enterprise', all)).toBe(false); + expect(isLinkVisible(schema, 'x:Odd/View', 'enterprise', all)).toBe(false); + expect(isLinkVisible(schema, 'x:Tenant', 'oss', all)).toBe(false); + expect(isLinkVisible(schema, 'x:Tenant', 'community', all)).toBe(true); + expect(isLinkVisible(schema, 'x:Tenant/Active', 'oss', all)).toBe(false); + }); +}); + +describe('isLinkVisible for the console’s own pages', () => { + it('shows dashboards to whoever may see live metrics and traces, never on open source', () => { + for (const view of ['Dashboard/overview', 'CustomComponent/Dashboard']) { + expect(isLinkVisible(schema, view, 'enterprise', canGetFrom(['sysTrace']), hasPermFrom(['liveMetrics']))).toBe( + true, + ); + expect(isLinkVisible(schema, view, 'enterprise', canGetFrom(['sysTrace']), hasPermFrom([]))).toBe(false); + expect(isLinkVisible(schema, view, 'enterprise', none, hasPermFrom(['liveMetrics']))).toBe(false); + expect(isLinkVisible(schema, view, 'oss', all, all)).toBe(false); + expect(isLinkVisible(schema, view, 'enterprise', canGetFrom(['sysTrace']))).toBe(true); + expect(isLinkVisible(schema, view, 'enterprise', none)).toBe(false); + } + }); + + it('shows the live delivery test and live tracing to whoever holds their permission', () => { + expect(isLinkVisible(schema, 'CustomComponent/LiveDelivery', 'oss', none, hasPermFrom(['liveDeliveryTest']))).toBe( + true, + ); + expect(isLinkVisible(schema, 'CustomComponent/LiveDelivery', 'oss', all, hasPermFrom([]))).toBe(false); + expect(isLinkVisible(schema, 'CustomComponent/LiveTracing', 'community', none, hasPermFrom(['liveTracing']))).toBe( + true, + ); + expect(isLinkVisible(schema, 'CustomComponent/LiveTracing', 'community', all, hasPermFrom([]))).toBe(false); + expect(isLinkVisible(schema, 'CustomComponent/LiveTracing', 'oss', all, all)).toBe(false); + }); + + const byReadPermission: [string, string][] = [ + ['CustomComponent/LegacyProtocols', 'sysSecurity'], + ['CustomComponent/LocalAi', 'sysSpamLlm'], + ['CustomComponent/AuditLog', 'sysAudit'], + ['CustomComponent/LegalHolds', 'sysLegalHold'], + ['CustomComponent/ComplianceOverview', 'sysCompliance'], + ['CustomComponent/DataInventory', 'sysCompliance'], + ['CustomComponent/DlpRules', 'sysDlpPolicy'], + ['CustomComponent/Journal', 'sysJournal'], + ['CustomComponent/HeldMail', 'sysDlpReview'], + ['CustomComponent/MailFlowRules', 'sysMailRule'], + ['CustomComponent/AccountLocks', 'sysAccountLock'], + ['CustomComponent/SharedMailboxes', 'sysAccountLock'], + ['CustomComponent/Deliverability', 'sysDeliverability'], + ['CustomComponent/ScheduledReports', 'sysScheduledReport'], + ['Wizard/dns/connect', 'sysDomain'], + ]; + it.each(byReadPermission)('shows %s to whoever may read %s, on any edition', (view, prefix) => { + expect(isLinkVisible(schema, view, 'oss', canGetFrom([prefix]))).toBe(true); + expect(isLinkVisible(schema, view, 'oss', canGetFrom([]))).toBe(false); + expect(isLinkEnterprise(schema, view)).toBe(false); + }); + + const wizards: [string, string[]][] = [ + ['Wizard/certificates', ['sysDomain', 'sysAcmeProvider', 'sysCertificate']], + ['Wizard/sending', ['sysMtaRoute', 'sysMtaOutboundStrategy']], + ['Wizard/directory', ['sysDirectory', 'sysDomain', 'sysAuthentication']], + ['Wizard/limits', ['sysMtaInboundThrottle', 'sysMtaOutboundThrottle', 'sysMtaQueueQuota']], + ]; + it.each(wizards)('opens %s to whoever may read everything it changes', (view, prefixes) => { + expect(isLinkVisible(schema, view, 'oss', canGetFrom(prefixes))).toBe(true); + for (const missing of prefixes) { + expect(isLinkVisible(schema, view, 'oss', canGetFrom(prefixes.filter((p) => p !== missing)))).toBe(false); + } + }); + + it('hides pages it doesn’t know', () => { + expect(isLinkVisible(schema, 'CustomComponent/FromTheFuture', 'enterprise', all, all)).toBe(false); + expect(isLinkVisible(schema, 'Wizard/unknown', 'enterprise', all, all)).toBe(false); + }); +}); + +describe('isLinkEnterprise and isLinkAccessible', () => { + it('marks enterprise pages, including views of enterprise objects', () => { + expect(isLinkEnterprise(schema, 'x:Tenant')).toBe(true); + expect(isLinkEnterprise(schema, 'x:Tenant/Active')).toBe(true); + expect(isLinkEnterprise(schema, 'x:Domain')).toBe(false); + expect(isLinkEnterprise(schema, 'x:Account/User')).toBe(false); + expect(isLinkEnterprise(schema, 'x:Odd/View')).toBe(false); + expect(isLinkEnterprise(schema, 'x:Nope')).toBe(false); + expect(isLinkEnterprise(schema, 'Dashboard/overview')).toBe(true); + expect(isLinkEnterprise(schema, 'CustomComponent/LiveTracing')).toBe(true); + expect(isLinkEnterprise(schema, 'CustomComponent/LiveDelivery')).toBe(false); + expect(isLinkEnterprise(schema, 'CustomComponent/FromTheFuture')).toBe(false); + }); + + it('shows enterprise pages to the community edition without letting it in', () => { + expect(isLinkVisible(schema, 'x:Tenant', 'community', all)).toBe(true); + expect(isLinkAccessible(schema, 'x:Tenant', 'community', all)).toBe(false); + expect(isLinkAccessible(schema, 'x:Tenant', 'enterprise', all)).toBe(true); + expect(isLinkAccessible(schema, 'x:Domain', 'community', all)).toBe(true); + expect(isLinkAccessible(schema, 'x:Domain', 'community', none)).toBe(false); + }); +}); + +describe('first links and visible layouts', () => { + const layout: Layout = { + name: 'Management', + icon: 'server', + items: [ + { + container: { + name: 'Directory', + icon: 'users', + items: [ + { type: 'container', name: 'Deep', items: [{ type: 'link', name: 'Tenants', viewName: 'x:Tenant' }] }, + { type: 'link', name: 'People', viewName: 'x:Account/User' }, + ], + }, + }, + { link: { name: 'Domains', icon: 'globe', viewName: 'x:Domain' } }, + ], + }; + const withLayouts = { ...schema, layouts: [layout, { name: 'Empty', icon: 'x', items: [] }] } as Schema; + + it('walks containers depth first, in order', () => { + expect(findFirstVisibleLinkInLayout(withLayouts, layout, 'community', all)).toBe('x:Tenant'); + expect(findFirstVisibleLinkInLayout(withLayouts, layout, 'oss', all)).toBe('x:Account/User'); + expect(findFirstVisibleLinkInLayout(withLayouts, layout, 'community', canGetFrom(['sysDomain']))).toBe('x:Domain'); + expect(findFirstVisibleLinkInLayout(withLayouts, layout, 'community', none)).toBeNull(); + }); + + it('skips enterprise pages for the first page the community edition may open', () => { + expect(findFirstAccessibleLinkInLayout(withLayouts, layout, 'community', all)).toBe('x:Account/User'); + expect(findFirstAccessibleLinkInLayout(withLayouts, layout, 'enterprise', all)).toBe('x:Tenant'); + expect(findFirstAccessibleLinkInLayout(withLayouts, layout, 'community', canGetFrom(['sysTenant']))).toBeNull(); + }); + + it('keeps the layouts with at least one page to show', () => { + expect(visibleLayouts(withLayouts, 'community', all).map((l) => l.name)).toEqual(['Management']); + expect(visibleLayouts(withLayouts, 'community', none)).toEqual([]); + }); +}); diff --git a/src/lib/navTree.test.ts b/src/lib/navTree.test.ts index 03f7583..e72aaab 100644 --- a/src/lib/navTree.test.ts +++ b/src/lib/navTree.test.ts @@ -5,9 +5,22 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { pathMatchesView } from './navTree'; +import { + SECTION_NAV_MAX_ITEMS, + checkIsEnterprise, + checkLinkVisible, + fitsSectionNav, + pathMatchesView, + resolveViewPath, + subtreeContainsActive, + subtreeHasVisibleLink, + topItemKey, + topItemVisible, + visibleLinks, +} from './navTree'; +import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; -import type { Schema } from '@/types/schema'; +import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema'; describe('pathMatchesView', () => { beforeEach(() => { @@ -34,3 +47,85 @@ describe('pathMatchesView', () => { expect(pathMatchesView(path, 'Settings', 'x:Metrics/CollectorPrometheus')).toBe(true); }); }); + +describe('walking the menu tree', () => { + const objects = { + 'x:Domain': { type: 'object', description: '', permissionPrefix: 'sysDomain' }, + 'x:Tenant': { type: 'object', description: '', permissionPrefix: 'sysTenant', enterprise: true }, + 'x:Account': { type: 'object', description: '', permissionPrefix: 'sysAccount' }, + }; + const tree: LayoutSubItem[] = [ + { type: 'link', name: '', viewName: 'x:Domain' }, + { + type: 'container', + name: 'Deeper', + items: [ + { type: 'link', name: 'Tenants', viewName: 'x:Tenant' }, + { type: 'link', name: 'People', viewName: 'x:Account' }, + ], + }, + { type: 'container', name: 'Empty', items: [] }, + ]; + const as = (edition: 'oss' | 'community' | 'enterprise', permissions: string[]) => { + useSchemaStore.setState({ schema: { objects, layouts: [] } as unknown as Schema }); + useAccountStore.setState({ edition, permissions }); + }; + + it('finds the active page anywhere under a container', () => { + as('enterprise', []); + expect(subtreeContainsActive(tree, '/Management/x:Account/b', 'Management')).toBe(true); + expect(subtreeContainsActive(tree, '/Management/x:Domain', 'Management')).toBe(true); + expect(subtreeContainsActive(tree, '/Settings/x:Domain', 'Management')).toBe(false); + expect(subtreeContainsActive(tree, '/Management/x:Other', 'Management')).toBe(false); + }); + + it('marks the dashboard link active on every dashboard', () => { + as('enterprise', []); + expect(pathMatchesView('/Management/Dashboard/delivery', 'Management', 'CustomComponent/Dashboard')).toBe(true); + expect(pathMatchesView('/Settings/Dashboard/delivery', 'Management', 'CustomComponent/Dashboard')).toBe(false); + expect(resolveViewPath('Management', 'x:Domain')).toBe('/Management/x:Domain'); + }); + + it('sees links by permission, and hides enterprise ones on open source', () => { + as('enterprise', ['sysTenantGet']); + expect(subtreeHasVisibleLink(tree, 'enterprise')).toBe(true); + expect(checkLinkVisible('x:Tenant')).toBe(true); + expect(checkLinkVisible('x:Domain')).toBe(false); + expect(checkIsEnterprise('x:Tenant')).toBe(true); + as('oss', ['sysTenantGet']); + expect(subtreeHasVisibleLink(tree, 'oss')).toBe(false); + as('community', []); + expect(subtreeHasVisibleLink(tree, 'community')).toBe(false); + }); + + it('flattens the visible links, naming deeper ones after their group', () => { + as('community', ['sysDomainGet', 'sysTenantGet', 'sysAccountGet']); + expect(visibleLinks(tree, 'community')).toEqual([ + { name: 'Overview', viewName: 'x:Domain' }, + { name: 'Deeper › Tenants', viewName: 'x:Tenant' }, + { name: 'Deeper › People', viewName: 'x:Account' }, + ]); + as('oss', ['sysDomainGet', 'sysTenantGet', 'sysAccountGet']); + expect(visibleLinks(tree, 'oss').map((l) => l.viewName)).toEqual(['x:Domain', 'x:Account']); + }); + + it('decides which top-level entries show, and whether a layout fits a menu bar', () => { + as('oss', ['sysTenantGet', 'sysAccountGet']); + const link: LayoutItem = { link: { name: 'Tenants', icon: 't', viewName: 'x:Tenant' } }; + const group: LayoutItem = { container: { name: 'Group', icon: 'g', items: tree } }; + expect(topItemVisible(link, 'oss')).toBe(false); + expect(topItemVisible(group, 'oss')).toBe(true); + expect(topItemKey(link)).toBe('x:Tenant'); + expect(topItemKey(group)).toBe('Group'); + const many = (n: number): Layout => ({ name: 'L', icon: 'l', items: Array.from({ length: n }, () => group) }); + expect(fitsSectionNav(many(SECTION_NAV_MAX_ITEMS), 'oss')).toBe(true); + expect(fitsSectionNav(many(SECTION_NAV_MAX_ITEMS + 1), 'oss')).toBe(false); + expect(fitsSectionNav({ ...many(SECTION_NAV_MAX_ITEMS + 1), items: [...many(12).items, link] }, 'oss')).toBe(true); + }); + + it('treats every link as visible before the schema loads', () => { + useSchemaStore.setState({ schema: null }); + expect(checkLinkVisible('x:Anything')).toBe(true); + expect(checkIsEnterprise('x:Anything')).toBe(false); + }); +}); diff --git a/src/lib/navTree.ts b/src/lib/navTree.ts index 3160203..6490764 100644 --- a/src/lib/navTree.ts +++ b/src/lib/navTree.ts @@ -1,27 +1,25 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ /** - * INBUXA: walking the server's menu tree, shared by the two shells that draw it - * — the sidebar (legacy) and the section bar (modern). The tree itself is the - * schema's `layouts`, so neither shell may assume a depth or a fan-out. + * Walking the server's menu tree for the two shells that draw it: the + * sidebar and the section bar. The tree is the schema's `layouts`, so + * neither may assume a depth or a fan-out. */ - import { useAccountStore } from '@/stores/accountStore'; import { useSchemaStore } from '@/stores/schemaStore'; import { isLinkEnterprise, isLinkVisible } from '@/lib/layout'; import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema'; /** - * Past this many top-level entries a layout is a configuration browser rather - * than a set of destinations, and a menu bar stops helping however wide the - * window: on the stock schema that is Settings, with its nineteen groups, which - * keeps the sidebar under either shell. Management (nine) and Account (eleven) - * fit, and anything left over folds into the section bar's "More". + * Past this many top-level entries a layout is a settings browser more than + * a set of places, and a menu bar stops helping however wide the window. On + * the stock schema that's Settings, with nineteen groups, which keeps the + * sidebar in either shell; Management (nine) and Account (eleven) fit, with + * anything left over in the section bar's "More". */ export const SECTION_NAV_MAX_ITEMS = 12; @@ -29,66 +27,55 @@ export function resolveViewPath(sectionName: string, viewName: string): string { return `/${sectionName}/${viewName}`; } +/** Whether the address is this link's page, or a record on it. */ export function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean { - const base = `/${sectionName}/${viewName}`; - if (currentPath === base) return true; - if (currentPath.startsWith(`${base}/`)) { - // inbuxa: /Settings/x:Metrics/CollectorPrometheus is its own page, not a record of - // x:Metrics, so General and Prometheus aren't both highlighted. - const next = decodeURIComponent(currentPath.slice(base.length + 1).split('/')[0]); + const page = resolveViewPath(sectionName, viewName); + if (currentPath === page) return true; + if (currentPath.startsWith(`${page}/`)) { + // A view under this one is its own page, not a record: x:Metrics/CollectorPrometheus + // shouldn't light up x:Metrics as well. + const next = decodeURIComponent(currentPath.slice(page.length + 1).split('/')[0]); return !useSchemaStore.getState().schema?.objects[`${viewName}/${next}`]; } - if (viewName === 'CustomComponent/Dashboard') { - const dashBase = `/${sectionName}/Dashboard/`; - return currentPath.startsWith(dashBase); - } - return false; + return viewName === 'CustomComponent/Dashboard' && currentPath.startsWith(`/${sectionName}/Dashboard/`); } +/** Whether the signed-in account may see this link; every link counts until the schema loads. */ export function checkLinkVisible(viewName: string): boolean { const schema = useSchemaStore.getState().schema; if (!schema) return true; - - const accountStore = useAccountStore.getState(); + const account = useAccountStore.getState(); return isLinkVisible( schema, viewName, - accountStore.edition, - (prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'), - (perm: string) => accountStore.hasPermission(perm), + account.edition, + (prefix) => account.hasObjectPermission(prefix, 'Get'), + (perm) => account.hasPermission(perm), ); } export function checkIsEnterprise(viewName: string): boolean { const schema = useSchemaStore.getState().schema; - if (!schema) return false; - const edition = useAccountStore.getState().edition; - return isLinkEnterprise(schema, viewName, edition); + return schema ? isLinkEnterprise(schema, viewName) : false; +} + +/** Visible to this account, and not an enterprise page on the open-source edition. */ +function linkShows(viewName: string, edition: string): boolean { + return checkLinkVisible(viewName) && !(edition === 'oss' && checkIsEnterprise(viewName)); } export function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean { - for (const item of items) { - if (item.type === 'link') { - if (pathMatchesView(currentPath, sectionName, item.viewName)) return true; - } else if (item.type === 'container') { - if (subtreeContainsActive(item.items, currentPath, sectionName)) return true; - } - } - return false; + return items.some((item) => + item.type === 'link' + ? pathMatchesView(currentPath, sectionName, item.viewName) + : subtreeContainsActive(item.items, currentPath, sectionName), + ); } export function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean { - for (const item of items) { - if (item.type === 'link') { - if (!checkLinkVisible(item.viewName)) continue; - const enterprise = checkIsEnterprise(item.viewName); - if (enterprise && edition === 'oss') continue; - return true; - } else if (item.type === 'container') { - if (subtreeHasVisibleLink(item.items, edition)) return true; - } - } - return false; + return items.some((item) => + item.type === 'link' ? linkShows(item.viewName, edition) : subtreeHasVisibleLink(item.items, edition), + ); } /** Every visible link under a container, flattened, deeper names prefixed with their group. */ @@ -97,27 +84,19 @@ export function visibleLinks( edition: string, prefix = '', ): { name: string; viewName: string }[] { - const out: { name: string; viewName: string }[] = []; - for (const it of items) { - if (it.type === 'link') { - if (!checkLinkVisible(it.viewName)) continue; - const enterprise = checkIsEnterprise(it.viewName); - if (enterprise && edition === 'oss') continue; - out.push({ name: `${prefix}${it.name || 'Overview'}`, viewName: it.viewName }); - } else if (subtreeHasVisibleLink(it.items, edition)) { - out.push(...visibleLinks(it.items, edition, `${prefix}${it.name} › `)); + return items.flatMap((item) => { + if (item.type === 'link') { + return linkShows(item.viewName, edition) + ? [{ name: `${prefix}${item.name || 'Overview'}`, viewName: item.viewName }] + : []; } - } - return out; + return visibleLinks(item.items, edition, `${prefix}${item.name} › `); + }); } /** Whether a top-level entry has anything left to show once edition and permissions are applied. */ export function topItemVisible(item: LayoutItem, edition: string): boolean { - if ('link' in item) { - if (!checkLinkVisible(item.link.viewName)) return false; - return !(checkIsEnterprise(item.link.viewName) && edition === 'oss'); - } - return subtreeHasVisibleLink(item.container.items, edition); + return 'link' in item ? linkShows(item.link.viewName, edition) : subtreeHasVisibleLink(item.container.items, edition); } /** A stable key for a top-level entry, for React lists. */ diff --git a/src/lib/shortcuts.test.ts b/src/lib/shortcuts.test.ts new file mode 100644 index 0000000..e60068f --- /dev/null +++ b/src/lib/shortcuts.test.ts @@ -0,0 +1,31 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { isCommandBarKey } from './shortcuts'; + +const key = (k: string, mods: Partial> = {}) => ({ + key: k, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + ...mods, +}); + +describe('isCommandBarKey', () => { + it('takes Ctrl+K and ⌘K, either case', () => { + expect(isCommandBarKey(key('k', { ctrlKey: true }))).toBe(true); + expect(isCommandBarKey(key('K', { metaKey: true }))).toBe(true); + }); + + it('ignores K alone or with Shift or Alt', () => { + expect(isCommandBarKey(key('k'))).toBe(false); + expect(isCommandBarKey(key('k', { ctrlKey: true, shiftKey: true }))).toBe(false); + expect(isCommandBarKey(key('k', { metaKey: true, altKey: true }))).toBe(false); + expect(isCommandBarKey(key('j', { ctrlKey: true }))).toBe(false); + }); +}); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index 36b0143..b518317 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -16,6 +16,13 @@ export const OPEN_COMMAND_BAR = 'inbuxa:open-command-bar'; /** Open the shortcut sheet (the account menu's Keyboard shortcuts). */ export const OPEN_SHORTCUTS = 'inbuxa:open-shortcuts'; +/** Ctrl+K, or ⌘K on a Mac, with no other modifier: opens or closes the command bar. */ +export function isCommandBarKey( + e: Pick, +): boolean { + return (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k'; +} + /** How long `g` waits for its second key. */ export const GO_WINDOW_MS = 1500; diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index e016fce..cd9c564 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -1,92 +1,71 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { useAuthStore } from '@/stores/authStore'; -import { useSchemaStore } from '@/stores/schemaStore'; -import { useAccountStore } from '@/stores/accountStore'; -import { useUIStore } from '@/stores/uiStore'; -import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client'; -import { getApiBaseUrl } from '@/services/api'; -import { loadAccountTheme, setAccountSettingsTarget } from '@/lib/accountSettings'; -import { setLocale } from '@/i18n'; + +import { ConnectionBanner } from '@/components/layout/ConnectionBanner'; +import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; +import { MainContent } from '@/components/layout/MainContent'; +import { SectionNav } from '@/components/layout/SectionNav'; +import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner'; +import { Sidebar } from '@/components/layout/Sidebar'; import { TopBar } from '@/components/layout/TopBar'; import { KeyboardShortcuts } from '@/components/common/KeyboardShortcuts'; -import { FirstDomainCelebration } from '@/features/celebrate/FirstDomainCelebration'; -import { Sidebar } from '@/components/layout/Sidebar'; -import { SectionNav } from '@/components/layout/SectionNav'; -import { MainContent } from '@/components/layout/MainContent'; -import { SettingsApplyBanner } from '@/components/layout/SettingsApplyBanner'; -import { ConnectionBanner } from '@/components/layout/ConnectionBanner'; -import { ExplainPanel } from '@/features/ai/explain/ExplainPanel'; -import { useExplainStore } from '@/features/ai/explain/explainStore'; -import { explainAvailable } from '@/features/ai/explain/explain'; -import { ErrorBoundary } from '@/components/layout/ErrorBoundary'; import { LoadingFallback } from '@/components/common/LoadingFallback'; -import { - findFirstAccessibleLinkInLayout, - findFirstVisibleLinkInLayout, - isLinkAccessible, - visibleLayouts, -} from '@/lib/layout'; -import { usePermissions } from '@/hooks/usePermissions'; +import { FirstDomainCelebration } from '@/features/celebrate/FirstDomainCelebration'; +import { ExplainPanel } from '@/features/ai/explain/ExplainPanel'; +import { explainAvailable } from '@/features/ai/explain/explain'; +import { useExplainStore } from '@/features/ai/explain/explainStore'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; -import { friendlyName } from '@/hooks/useGlobalSearch'; -import { rememberLastVisited, sectionLandingLink } from '@/lib/lastVisited'; +import { usePermissions } from '@/hooks/usePermissions'; +import { setLocale } from '@/i18n'; +import { loadAccountTheme, setAccountSettingsTarget } from '@/lib/accountSettings'; +import { rememberLastVisited } from '@/lib/lastVisited'; +import { visibleLayouts } from '@/lib/layout'; import { fitsSectionNav } from '@/lib/navTree'; import { cn } from '@/lib/utils'; +import { getApiBaseUrl } from '@/services/api'; +import { fetchAccountInfo, fetchSchema, fetchSession } from '@/services/jmap/client'; +import { useAccountStore } from '@/stores/accountStore'; +import { useAuthStore } from '@/stores/authStore'; +import { useSchemaStore } from '@/stores/schemaStore'; +import { useUIStore } from '@/stores/uiStore'; + +import { adminPageTitle, landing, parseAdminPath, permissionChecks, readSessionInfo } from './adminRoute'; const BootstrapWizard = lazy(() => import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })), ); +/** + * A development build may carry an access token, and a refresh token so it + * renews itself like a real sign-in instead of dying with the access token. + */ +function useDevelopmentToken(accessToken: string | null) { + useEffect(() => { + const token = import.meta.env.VITE_ACCESS_TOKEN; + if (!token || accessToken) return; + const refresh = (import.meta.env.VITE_REFRESH_TOKEN as string | undefined) ?? ''; + const expiresIn = Number(import.meta.env.VITE_ACCESS_TOKEN_EXPIRES_IN ?? 86400); + useAuthStore.getState().setTokens(token, refresh, expiresIn, refresh ? `${getApiBaseUrl()}/auth/token` : ''); + }, [accessToken]); +} + +/** The console after sign-in: loads the session, schema and account once, then draws the shell around the page. */ export default function AdminPanel() { const { t } = useTranslation(); const navigate = useNavigate(); - const { section, '*': splat } = useParams<{ - section?: string; - '*'?: string; - }>(); - - let viewName: string | undefined; - let id: string | undefined; - if (splat) { - const parts = splat.split('/'); - if (parts[parts.length - 1] === 'new') { - id = 'new'; - viewName = parts.slice(0, -1).join('/'); - } else if (parts[parts.length - 1] === 'singleton') { - id = 'singleton'; - viewName = parts.slice(0, -1).join('/'); - } else { - const schemaStore = useSchemaStore.getState(); - const fullAsView = parts.join('/'); - if (schemaStore.schema?.objects[fullAsView]) { - viewName = fullAsView; - } else if (parts.length > 1) { - const candidateView = parts.slice(0, -1).join('/'); - if (schemaStore.schema?.objects[candidateView]) { - viewName = candidateView; - id = parts[parts.length - 1]; - } else { - viewName = fullAsView; - } - } else { - viewName = fullAsView; - } - } - } + const { section, '*': splat } = useParams<{ section?: string; '*'?: string }>(); + const { viewName, id } = parseAdminPath(splat, useSchemaStore.getState().schema?.objects); const isSchemaLoaded = useSchemaStore((s) => s.isLoaded); const schema = useSchemaStore((s) => s.schema); + const searchIndex = useSchemaStore((s) => s.searchIndex); const setSchema = useSchemaStore((s) => s.setSchema); const setAccountInfo = useAccountStore((s) => s.setAccountInfo); const edition = useAccountStore((s) => s.edition); @@ -94,8 +73,8 @@ export default function AdminPanel() { const setSession = useAuthStore((s) => s.setSession); const accessToken = useAuthStore((s) => s.accessToken); const activeAccountId = useAuthStore((s) => s.activeAccountId); - const setActiveSection = useUIStore((s) => s.setActiveSection); const activeSection = useUIStore((s) => s.activeSection); + const setActiveSection = useUIStore((s) => s.setActiveSection); const adminLayout = useUIStore((s) => s.adminLayout); const sidebarOpen = useUIStore((s) => s.sidebarOpen); const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed); @@ -104,197 +83,81 @@ export default function AdminPanel() { const [initError, setInitError] = useState(null); const [initializing, setInitializing] = useState(!isSchemaLoaded); - const searchIndex = useSchemaStore((s) => s.searchIndex); - const pageTitle = useMemo(() => { - if (!section) return t('dashboard.title', 'Dashboard'); - if (!viewName) return section; - if (viewName.startsWith('Wizard/dns/')) return `${t('dnsWizard.tabTitle', 'Publish DNS')} · ${section}`; - if (viewName === 'Wizard/limits') return `${t('limitsSetup.tabTitle', 'Limits')} · ${section}`; - if (viewName === 'Wizard/directory') return `${t('dirSetup.tabTitle', 'Sign-in directory')} · ${section}`; - if (viewName === 'Wizard/certificates') return `${t('certSetup.tabTitle', 'Certificates')} · ${section}`; - if (viewName === 'Wizard/sending') return `${t('sendingSetup.tabTitle', 'Sending mail')} · ${section}`; - if (viewName.startsWith('Dashboard/')) { - const boardId = viewName.slice('Dashboard/'.length); - // INBUXA: the landing page is the command center; Cluster isn't in the schema. - if (boardId === 'overview') return t('cc.back', 'Command center'); - if (boardId === 'cluster') return `${t('cc.cluster', 'Cluster')} · ${t('dashboard.title', 'Dashboard')}`; - const board = schema?.dashboards?.find((d) => d.id === boardId); - return `${board?.label ?? t('dashboard.title', 'Dashboard')} · ${t('dashboard.title', 'Dashboard')}`; - } - let label: string | undefined; - for (const entry of searchIndex) { - if (entry.type !== 'link' || entry.viewName !== viewName) continue; - if (entry.section === section) { - label = entry.text; - break; - } - label ??= entry.text; - } - const name = label ?? friendlyName(viewName); - const title = id === 'new' ? t('form.createTitle', 'Create {{name}}', { name }) : name; - return `${title} · ${section}`; - }, [section, viewName, id, searchIndex, schema, t]); - - useDocumentTitle(pageTitle); - - useEffect(() => { - const bypassToken = import.meta.env.VITE_ACCESS_TOKEN; - if (bypassToken && !accessToken) { - // INBUXA: with a refresh token as well, the dev bypass renews itself like a real - // sign-in instead of dying when the access token does. - const bypassRefresh = (import.meta.env.VITE_REFRESH_TOKEN as string | undefined) ?? ''; - const expiresIn = Number(import.meta.env.VITE_ACCESS_TOKEN_EXPIRES_IN ?? 86400); - useAuthStore - .getState() - .setTokens(bypassToken, bypassRefresh, expiresIn, bypassRefresh ? `${getApiBaseUrl()}/auth/token` : ''); - } - }, [accessToken]); + useDocumentTitle( + useMemo( + () => adminPageTitle(t, { section, viewName, id, searchIndex, dashboards: schema?.dashboards }), + [section, viewName, id, searchIndex, schema, t], + ), + ); + useDevelopmentToken(accessToken); useEffect(() => { if (isSchemaLoaded) return; - let cancelled = false; - async function init() { + (async () => { try { const session = await fetchSession(); - const accounts: Record = {}; - for (const [accountId, info] of Object.entries( - session.accounts as Record, - )) { - accounts[accountId] = { name: info.name, isPersonal: info.isPersonal }; - } - const primaryAccounts = session.primaryAccounts as Record; - const primaryAccountId = - primaryAccounts['urn:ietf:params:jmap:core'] ?? Object.values(primaryAccounts)[0] ?? Object.keys(accounts)[0]; - const apiUrl = (session.apiUrl as string) || '/jmap'; - - const capabilities = session.capabilities as Record> | undefined; - const coreCapability = capabilities?.['urn:ietf:params:jmap:core']; - const maxObjectsInGet = - typeof coreCapability?.maxObjectsInGet === 'number' ? coreCapability.maxObjectsInGet : undefined; - const maxObjectsInSet = - typeof coreCapability?.maxObjectsInSet === 'number' ? coreCapability.maxObjectsInSet : undefined; - + const info = readSessionInfo(session); if (cancelled) return; - setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet); - // inbuxa: whether "Explain this" is offered (ai-explain EX-1 to EX-4) - useExplainStore.getState().setAvailable(explainAvailable(session, primaryAccountId)); + setSession(info.accounts, info.primaryAccountId, info.apiUrl, info.maxObjectsInGet, info.maxObjectsInSet); + useExplainStore.getState().setAvailable(explainAvailable(session, info.primaryAccountId)); useAuthStore.getState().setUsername(typeof session.username === 'string' ? session.username : null); - // INBUXA: the theme lives with the account, shared with the webmail. + // The theme lives with the account, shared with the webmail. setAccountSettingsTarget(session); void loadAccountTheme().then((stored) => { if (stored && !cancelled) useUIStore.getState().applyAccountTheme(stored.palette, stored.mode); }); - const [schemaData, accountData] = await Promise.all([fetchSchema(), fetchAccountInfo()]); - + const [schemaData, account] = await Promise.all([fetchSchema(), fetchAccountInfo()]); if (cancelled) return; - setSchema(schemaData); - - setAccountInfo(accountData.permissions, accountData.edition, accountData.locale); - setLocale(accountData.locale); - + setAccountInfo(account.permissions, account.edition, account.locale); + setLocale(account.locale); setInitializing(false); } catch (err) { - if (!cancelled) { - console.error('Initialization failed:', err); - setInitError(err instanceof Error ? err.message : t('errors.unexpectedError')); - setInitializing(false); - } + if (cancelled) return; + console.error('Initialization failed:', err); + setInitError(err instanceof Error ? err.message : t('errors.unexpectedError')); + setInitializing(false); } - } + })(); - init(); return () => { cancelled = true; }; }, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]); - /** - * INBUXA: which shell draws the navigation. The modern one puts the layout's - * items in a bar under the top bar and drops the sidebar; a layout with too - * many top-level groups for a menu bar keeps the sidebar whatever the reader - * chose, so Settings never turns into nineteen dropdowns. - */ - const currentLayout = useMemo(() => { - if (!schema) return null; - const canGet = (prefix: string) => permissions.includes(`${prefix}Get`); - const hasPerm = (perm: string) => permissions.includes(perm); - const name = (section ?? activeSection ?? '').toLowerCase(); - return visibleLayouts(schema, edition, canGet, hasPerm).find((l) => l.name.toLowerCase() === name) ?? null; - }, [schema, edition, permissions, section, activeSection]); + const layouts = useMemo(() => { + if (!schema) return []; + const { canGet, hasPerm } = permissionChecks(permissions); + return visibleLayouts(schema, edition, canGet, hasPerm); + }, [schema, edition, permissions]); + // A layout with too many top-level groups for a menu bar keeps the sidebar + // whatever the reader chose, so Settings never turns into nineteen dropdowns. + const currentLayout = useMemo(() => { + const name = (section ?? activeSection ?? '').toLowerCase(); + return layouts.find((l) => l.name.toLowerCase() === name) ?? null; + }, [layouts, section, activeSection]); const useSectionNav = adminLayout === 'modern' && currentLayout !== null && fitsSectionNav(currentLayout, edition); - const isBootstrapMode = useMemo(() => { - if (!schema) return false; - if (!canViewObject('x:Bootstrap')) return false; - const canGet = (prefix: string) => permissions.includes(`${prefix}Get`); - const hasPerm = (perm: string) => permissions.includes(perm); - return visibleLayouts(schema, edition, canGet, hasPerm).length === 0; - }, [schema, canViewObject, edition, permissions]); + // An administrator who may only run the first-boot setup sees no layout at all. + const isBootstrapMode = schema !== null && canViewObject('x:Bootstrap') && layouts.length === 0; useEffect(() => { - if (!schema) return; - if (isBootstrapMode) return; - const canGet = (prefix: string) => permissions.includes(`${prefix}Get`); - const hasPerm = (perm: string) => permissions.includes(perm); - const layouts = visibleLayouts(schema, edition, canGet, hasPerm); - const pickDefault = (): { layoutName: string; link: string | null } | null => { - for (const layout of layouts) { - const link = findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPerm); - if (link) return { layoutName: layout.name, link }; - } - if (layouts[0]) { - return { - layoutName: layouts[0].name, - link: findFirstVisibleLinkInLayout(schema, layouts[0], edition, canGet, hasPerm), - }; - } - return null; - }; - - const redirectTo = (layoutName: string, link: string | null) => { - setActiveSection(layoutName); - if (link) navigate(`/${layoutName}/${link}`, { replace: true }); - }; - - if (section && viewName && !isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) { - const fallback = pickDefault(); - if (fallback && (fallback.layoutName !== section || fallback.link !== viewName)) { - redirectTo(fallback.layoutName, fallback.link); - return; - } + if (!schema || isBootstrapMode) return; + const next = landing(schema, edition, permissions, section, viewName); + if (!next) return; + setActiveSection(next.section); + if (next.kind === 'redirect') { + if (next.link) navigate(`/${next.section}/${next.link}`, { replace: true }); + } else if (next.remember) { + rememberLastVisited(next.remember.section, next.remember.viewName); } - - if (section && viewName) { - const ownLayout = layouts.find((l) => l.name.toLowerCase() === section.toLowerCase()); - setActiveSection(ownLayout?.name ?? layouts[0]?.name ?? section); - if (ownLayout) rememberLastVisited(ownLayout.name, viewName); - return; - } - - if (section) { - const ownLayout = layouts.find((l) => l.name.toLowerCase() === section.toLowerCase()); - const ownLink = ownLayout ? sectionLandingLink(schema, ownLayout, edition, canGet, hasPerm) : null; - if (ownLayout && ownLink) { - redirectTo(ownLayout.name, ownLink); - } else { - const target = pickDefault(); - if (target) redirectTo(target.layoutName, target.link); - } - return; - } - - const target = pickDefault(); - if (target) redirectTo(target.layoutName, target.link); }, [section, viewName, schema, setActiveSection, navigate, edition, permissions, isBootstrapMode]); - if (initializing) { - return ; - } + if (initializing) return ; if (initError) { return ( @@ -327,7 +190,6 @@ export default function AdminPanel() { return (
- {/* inbuxa: keyboard users can jump past the menus (roadmap item 13) */} {t('a11y.skip', 'Skip to content')} diff --git a/src/pages/adminRoute.test.ts b/src/pages/adminRoute.test.ts new file mode 100644 index 0000000..ee6c07a --- /dev/null +++ b/src/pages/adminRoute.test.ts @@ -0,0 +1,232 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import type { TFunction } from 'i18next'; +import { adminPageTitle, landing, parseAdminPath, permissionChecks, readSessionInfo } from './adminRoute'; +import type { Schema } from '@/types/schema'; +import type { SearchIndexEntry } from '@/stores/schemaStore'; + +const t = ((_key: string, fallback: string, vars?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_, k: string) => vars?.[k] ?? '')) as unknown as TFunction; + +const objects = { + 'x:Domain': { type: 'object', description: '', permissionPrefix: 'sysDomain' }, + 'x:Account': { type: 'object', description: '', permissionPrefix: 'sysAccount' }, + 'x:Account/User': { type: 'view', objectName: 'x:Account' }, + 'x:Tenant': { type: 'object', description: '', permissionPrefix: 'sysTenant', enterprise: true }, + 'x:MtaStageEhlo': { type: 'singleton', description: '', permissionPrefix: 'sysMtaStageEhlo' }, +} as unknown as Schema['objects']; + +describe('parseAdminPath', () => { + it('reads nothing without a path', () => { + expect(parseAdminPath(undefined, objects)).toEqual({}); + expect(parseAdminPath('', objects)).toEqual({}); + }); + + it('takes new and singleton as the record', () => { + expect(parseAdminPath('x:Domain/new', objects)).toEqual({ viewName: 'x:Domain', id: 'new' }); + expect(parseAdminPath('x:Account/User/new', objects)).toEqual({ viewName: 'x:Account/User', id: 'new' }); + expect(parseAdminPath('x:MtaStageEhlo/singleton', objects)).toEqual({ + viewName: 'x:MtaStageEhlo', + id: 'singleton', + }); + }); + + it('prefers a known view over a record id', () => { + expect(parseAdminPath('x:Account/User', objects)).toEqual({ viewName: 'x:Account/User' }); + expect(parseAdminPath('x:Domain', objects)).toEqual({ viewName: 'x:Domain' }); + }); + + it('splits off a record id after a known view', () => { + expect(parseAdminPath('x:Domain/b', objects)).toEqual({ viewName: 'x:Domain', id: 'b' }); + expect(parseAdminPath('x:Account/User/b', objects)).toEqual({ viewName: 'x:Account/User', id: 'b' }); + }); + + it('keeps an unknown path whole, as for the console’s own pages', () => { + expect(parseAdminPath('Dashboard/overview', objects)).toEqual({ viewName: 'Dashboard/overview' }); + expect(parseAdminPath('Wizard/dns/b', objects)).toEqual({ viewName: 'Wizard/dns/b' }); + expect(parseAdminPath('x:Domain/b', undefined)).toEqual({ viewName: 'x:Domain/b' }); + }); +}); + +describe('adminPageTitle', () => { + const searchIndex = [ + { text: 'Domains (elsewhere)', type: 'link', viewName: 'x:Domain', section: 'Settings', breadcrumb: '' }, + { text: 'Domain name', type: 'field', viewName: 'x:Domain', section: 'Management', breadcrumb: '' }, + { text: 'Domains', type: 'link', viewName: 'x:Domain', section: 'Management', breadcrumb: '' }, + { text: 'People', type: 'link', viewName: 'x:Account/User', section: 'Management', breadcrumb: '' }, + ] as SearchIndexEntry[]; + const dashboards = [{ id: 'delivery', label: 'Delivery' }] as Schema['dashboards']; + const title = (section?: string, viewName?: string, id?: string) => + adminPageTitle(t, { section, viewName, id, searchIndex, dashboards }); + + it('falls back to the section, then to Dashboard', () => { + expect(title()).toBe('Dashboard'); + expect(title('Management')).toBe('Management'); + }); + + it('names a page after its link in this section, else any section, else its view name', () => { + expect(title('Management', 'x:Domain')).toBe('Domains · Management'); + expect(title('Account', 'x:Domain')).toBe('Domains (elsewhere) · Account'); + expect(title('Management', 'x:Domain', 'b')).toBe('Domains · Management'); + expect(title('Management', 'x:Domain', 'new')).toBe('Create Domains · Management'); + expect(title('Management', 'x:Mystery')).toMatch(/ · Management$/); + }); + + it('names the guided jobs', () => { + expect(title('Management', 'Wizard/dns/b')).toBe('Publish DNS · Management'); + expect(title('Settings', 'Wizard/limits')).toBe('Limits · Settings'); + expect(title('Settings', 'Wizard/directory')).toBe('Sign-in directory · Settings'); + expect(title('Settings', 'Wizard/certificates')).toBe('Certificates · Settings'); + expect(title('Settings', 'Wizard/sending')).toBe('Sending mail · Settings'); + }); + + it('names the dashboards', () => { + expect(title('Management', 'Dashboard/overview')).toBe('Command center'); + expect(title('Management', 'Dashboard/cluster')).toBe('Cluster · Dashboard'); + expect(title('Management', 'Dashboard/delivery')).toBe('Delivery · Dashboard'); + expect(title('Management', 'Dashboard/gone')).toBe('Dashboard · Dashboard'); + }); +}); + +describe('readSessionInfo', () => { + it('keeps the accounts, the primary account, the API address and the limits', () => { + const info = readSessionInfo({ + accounts: { a: { name: 'admin', isPersonal: true, extra: 1 }, b: { name: 'shared', isPersonal: false } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'b', 'urn:ietf:params:jmap:core': 'a' }, + apiUrl: '/jmap/', + capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500, maxObjectsInSet: '9' } }, + }); + expect(info).toEqual({ + accounts: { a: { name: 'admin', isPersonal: true }, b: { name: 'shared', isPersonal: false } }, + primaryAccountId: 'a', + apiUrl: '/jmap/', + maxObjectsInGet: 500, + maxObjectsInSet: undefined, + }); + }); + + it('falls back to any primary account, then the first account, and /jmap', () => { + const accounts = { z: { name: 'z', isPersonal: true } }; + expect(readSessionInfo({ accounts, primaryAccounts: { mail: 'y' } }).primaryAccountId).toBe('y'); + expect(readSessionInfo({ accounts, primaryAccounts: {} })).toMatchObject({ + primaryAccountId: 'z', + apiUrl: '/jmap', + }); + }); +}); + +describe('permissionChecks', () => { + it('reads Get permissions by prefix, and others by name', () => { + const { canGet, hasPerm } = permissionChecks(['sysDomainGet', 'liveTracing']); + expect(canGet('sysDomain')).toBe(true); + expect(canGet('sysAccount')).toBe(false); + expect(hasPerm('liveTracing')).toBe(true); + expect(hasPerm('sysDomainGet')).toBe(true); + }); +}); + +describe('landing', () => { + const schema = { + objects, + layouts: [ + { + name: 'Management', + icon: 'server', + items: [ + { link: { name: 'Tenants', icon: 't', viewName: 'x:Tenant' } }, + { link: { name: 'People', icon: 'p', viewName: 'x:Account/User' } }, + { link: { name: 'Domains', icon: 'g', viewName: 'x:Domain' } }, + ], + }, + { + name: 'Settings', + icon: 's', + items: [{ link: { name: 'EHLO', icon: 'e', viewName: 'x:MtaStageEhlo' } }], + }, + ], + } as unknown as Schema; + const everything = ['sysDomainGet', 'sysAccountGet', 'sysTenantGet', 'sysMtaStageEhloGet']; + + beforeEach(() => { + const store = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + }); + }); + + it('opens the first page that can be opened when no section is given', () => { + expect(landing(schema, 'community', everything, undefined, undefined)).toEqual({ + kind: 'redirect', + section: 'Management', + link: 'x:Account/User', + }); + expect(landing(schema, 'enterprise', everything, undefined, undefined)).toMatchObject({ link: 'x:Tenant' }); + }); + + it('falls back to a visible page, then to nothing', () => { + expect(landing(schema, 'community', ['sysTenantGet'], undefined, undefined)).toEqual({ + kind: 'redirect', + section: 'Management', + link: 'x:Tenant', + }); + expect(landing(schema, 'community', [], undefined, undefined)).toBeNull(); + }); + + it('stays on a page that can be opened, naming its section as the layout spells it', () => { + expect(landing(schema, 'enterprise', everything, 'management', 'x:Domain')).toEqual({ + kind: 'stay', + section: 'Management', + remember: { section: 'Management', viewName: 'x:Domain' }, + }); + }); + + it('stays without remembering when the section is not one of the account’s', () => { + expect(landing(schema, 'enterprise', ['sysDomainGet'], 'Settings', 'x:Domain')).toEqual({ + kind: 'stay', + section: 'Management', + remember: null, + }); + }); + + it('sends a page that cannot be opened to the first one that can', () => { + expect(landing(schema, 'community', everything, 'Settings', 'x:Tenant')).toEqual({ + kind: 'redirect', + section: 'Management', + link: 'x:Account/User', + }); + expect(landing(schema, 'community', everything, 'Management', 'x:Nope')).toMatchObject({ link: 'x:Account/User' }); + }); + + it('stays when that first page is the one asked for', () => { + expect(landing(schema, 'community', ['sysTenantGet'], 'Management', 'x:Tenant')).toEqual({ + kind: 'stay', + section: 'Management', + remember: { section: 'Management', viewName: 'x:Tenant' }, + }); + }); + + it('opens a section where it was last left, else on its first page', () => { + expect(landing(schema, 'enterprise', everything, 'settings', undefined)).toEqual({ + kind: 'redirect', + section: 'Settings', + link: 'x:MtaStageEhlo', + }); + localStorage.setItem('inbuxa-last-visited', JSON.stringify({ Management: 'x:Domain' })); + expect(landing(schema, 'enterprise', everything, 'Management', undefined)).toMatchObject({ link: 'x:Domain' }); + }); + + it('opens the first section when the one asked for is not the account’s', () => { + expect(landing(schema, 'enterprise', ['sysDomainGet'], 'Settings', undefined)).toEqual({ + kind: 'redirect', + section: 'Management', + link: 'x:Domain', + }); + }); +}); diff --git a/src/pages/adminRoute.ts b/src/pages/adminRoute.ts new file mode 100644 index 0000000..cc806f4 --- /dev/null +++ b/src/pages/adminRoute.ts @@ -0,0 +1,185 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** The console's routing decisions: reading the address, the tab title, the session, and where to land. */ +import type { TFunction } from 'i18next'; +import type { Dashboard, Schema } from '@/types/schema'; +import type { SearchIndexEntry } from '@/stores/schemaStore'; +import { + findFirstAccessibleLinkInLayout, + findFirstVisibleLinkInLayout, + isLinkAccessible, + visibleLayouts, + type CanGet, + type HasPermission, +} from '@/lib/layout'; +import { sectionLandingLink } from '@/lib/lastVisited'; +import { friendlyName } from '@/hooks/useGlobalSearch'; + +/** + * The page and record an address names, from what follows the section. + * A trailing `new` or `singleton` is the record; otherwise the whole path + * is a view if the schema knows it, else all but the last part is a view + * and the last part a record id. + */ +export function parseAdminPath( + splat: string | undefined, + objects: Schema['objects'] | undefined, +): { viewName?: string; id?: string } { + if (!splat) return {}; + const parts = splat.split('/'); + const last = parts[parts.length - 1]; + const head = parts.slice(0, -1).join('/'); + if (last === 'new' || last === 'singleton') return { viewName: head, id: last }; + if (objects?.[splat] || parts.length === 1 || !objects?.[head]) return { viewName: splat }; + return { viewName: head, id: last }; +} + +interface TitleContext { + section?: string; + viewName?: string; + id?: string; + searchIndex: SearchIndexEntry[]; + dashboards?: Dashboard[]; +} + +function wizardTitle(t: TFunction, viewName: string): string | null { + if (viewName.startsWith('Wizard/dns/')) return t('dnsWizard.tabTitle', 'Publish DNS'); + switch (viewName) { + case 'Wizard/limits': + return t('limitsSetup.tabTitle', 'Limits'); + case 'Wizard/directory': + return t('dirSetup.tabTitle', 'Sign-in directory'); + case 'Wizard/certificates': + return t('certSetup.tabTitle', 'Certificates'); + case 'Wizard/sending': + return t('sendingSetup.tabTitle', 'Sending mail'); + } + return null; +} + +/** The page's name as it appears in the navigation: this section's link first, else any. */ +function linkLabel(searchIndex: SearchIndexEntry[], viewName: string, section: string): string | undefined { + const links = searchIndex.filter((e) => e.type === 'link' && e.viewName === viewName); + return (links.find((e) => e.section === section) ?? links[0])?.text; +} + +/** The browser tab's title for a page: its name, then the section. */ +export function adminPageTitle(t: TFunction, { section, viewName, id, searchIndex, dashboards }: TitleContext): string { + const dashboard = t('dashboard.title', 'Dashboard'); + if (!section) return dashboard; + if (!viewName) return section; + + const wizard = wizardTitle(t, viewName); + if (wizard) return `${wizard} · ${section}`; + + if (viewName.startsWith('Dashboard/')) { + const boardId = viewName.slice('Dashboard/'.length); + // The landing page is the command center; Cluster isn't in the schema. + if (boardId === 'overview') return t('cc.back', 'Command center'); + if (boardId === 'cluster') return `${t('cc.cluster', 'Cluster')} · ${dashboard}`; + return `${dashboards?.find((d) => d.id === boardId)?.label ?? dashboard} · ${dashboard}`; + } + + const name = linkLabel(searchIndex, viewName, section) ?? friendlyName(viewName); + return `${id === 'new' ? t('form.createTitle', 'Create {{name}}', { name }) : name} · ${section}`; +} + +export interface SessionInfo { + accounts: Record; + primaryAccountId: string; + apiUrl: string; + maxObjectsInGet?: number; + maxObjectsInSet?: number; +} + +const numberOrUndefined = (v: unknown) => (typeof v === 'number' ? v : undefined); + +/** What the console keeps from the JMAP session. */ +export function readSessionInfo(session: Record): SessionInfo { + const accounts = Object.fromEntries( + Object.entries(session.accounts as Record).map( + ([id, { name, isPersonal }]) => [id, { name, isPersonal }], + ), + ); + const primary = session.primaryAccounts as Record; + const core = (session.capabilities as Record> | undefined)?.[ + 'urn:ietf:params:jmap:core' + ]; + return { + accounts, + primaryAccountId: primary['urn:ietf:params:jmap:core'] ?? Object.values(primary)[0] ?? Object.keys(accounts)[0], + apiUrl: (session.apiUrl as string) || '/jmap', + maxObjectsInGet: numberOrUndefined(core?.maxObjectsInGet), + maxObjectsInSet: numberOrUndefined(core?.maxObjectsInSet), + }; +} + +/** Permission checks over the account's permission list. */ +export function permissionChecks(permissions: string[]): { canGet: CanGet; hasPerm: HasPermission } { + return { + canGet: (prefix) => permissions.includes(`${prefix}Get`), + hasPerm: (perm) => permissions.includes(perm), + }; +} + +export type Landing = + /** Go to this section's page; with no page, only mark the section as current. */ + | { kind: 'redirect'; section: string; link: string | null } + /** Stay, marking this section as current, and remember the page for its section. */ + | { kind: 'stay'; section: string; remember: { section: string; viewName: string } | null } + | null; + +/** + * Where an address leads. A page the account can't open goes to its first + * page it can; a section alone opens where it was last left; no section at + * all opens the first section with a page to show. + */ +export function landing( + schema: Schema, + edition: string, + permissions: string[], + section: string | undefined, + viewName: string | undefined, +): Landing { + const { canGet, hasPerm } = permissionChecks(permissions); + const layouts = visibleLayouts(schema, edition, canGet, hasPerm); + const own = section ? layouts.find((l) => l.name.toLowerCase() === section.toLowerCase()) : undefined; + + const firstPage = (): Landing => { + for (const layout of layouts) { + const link = findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPerm); + if (link) return { kind: 'redirect', section: layout.name, link }; + } + const [first] = layouts; + if (!first) return null; + return { + kind: 'redirect', + section: first.name, + link: findFirstVisibleLinkInLayout(schema, first, edition, canGet, hasPerm), + }; + }; + + if (section && viewName) { + if (!isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) { + const fallback = firstPage(); + if (fallback?.kind === 'redirect' && (fallback.section !== section || fallback.link !== viewName)) { + return fallback; + } + } + return { + kind: 'stay', + section: own?.name ?? layouts[0]?.name ?? section, + remember: own ? { section: own.name, viewName } : null, + }; + } + + if (own) { + const link = sectionLandingLink(schema, own, edition, canGet, hasPerm); + if (link) return { kind: 'redirect', section: own.name, link }; + } + return firstPage(); +} -- 2.54.0