Rewrite the panel, sidebar, top bar, page routing and layout rules #132

Merged
jcoffey-dev merged 1 commits from phase5/shell into main 2026-10-19 05:21:30 +00:00
17 changed files with 1782 additions and 1436 deletions

No files matched your search

+2 -10
View File
@@ -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<Tone, string> = {
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<string, unknown>)[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({
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, TONE_CLASSES[toneFor(name)], className)}
>
{createElement(iconComponent(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })}
{createElement(iconByName(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })}
</span>
);
}
+244 -416
View File
@@ -1,180 +1,291 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
return lazy(() => load().then((module) => ({ default: select(module) })));
import { pageRoute } from './pageRoute';
/** A page loaded the first time it's opened. */
function onDemand<M, P>(load: () => Promise<M>, pick: (module: M) => ComponentType<P>) {
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<string, ComponentType> = {
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<string, ComponentType> = {
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) => <DynamicForm viewName={viewName} objectId="singleton" />;
const reportsPage = (viewName: string) => <ReportsPage viewName={viewName} />;
/** Settings pages that open with something above, or in place of, the plain form. */
const SINGLETON_PAGES: Record<string, (viewName: string) => ReactNode> = {
'x:Authentication': (v) => stacked(<DirectorySetupCard className="mx-auto max-w-4xl" />, settingsForm(v)),
// Sender checks open with a choice of level.
'x:SenderAuth': (v) => <SenderChecksPage viewName={v} />,
// Every retention page opens with all the "how long"s as sentences.
'x:DataRetention': (v) => <RetentionPage viewName={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) => <MetricsPage viewName={v} />,
// Spam filter › General opens with how hard the filter is.
'x:SpamSettings': (v) => <SpamSettingsPage viewName={v} />,
'x:Sharing': (v) => stacked(<SharingSwitches />, settingsForm(v)),
'x:Security': (v) => stacked(<LegacyProtocolsBanner />, settingsForm(v)),
'x:MtaOutboundStrategy': (v) => stacked(<SendingSetupCard className="mx-auto max-w-4xl" />, settingsForm(v)),
};
const listUnder = (above: ReactNode) => (viewName: string) => stacked(above, <DynamicList viewName={viewName} />);
const limitsList = (object: LimitObject) => () =>
stacked(<LimitsSummary object={object} />, <DynamicList viewName={object} />);
/** Lists that open with a guide or a summary above them, or have a page of their own. */
const LIST_PAGES: Record<string, (viewName: string) => ReactNode> = {
'x:Certificate': listUnder(<CertificateSetupCard />),
'x:AcmeProvider': listUnder(<CertificateSetupCard />),
// Received reports open with who sends as us and whether they pass.
'x:DmarcExternalReport': listUnder(<DmarcSummaryCard />),
'x:TlsExternalReport': listUnder(<TlsSummaryCard />),
'x:MtaRoute': listUnder(<SendingSetupCard />),
'x:Directory': listUnder(<DirectorySetupCard />),
'x:MtaInboundThrottle': limitsList('x:MtaInboundThrottle'),
'x:MtaOutboundThrottle': limitsList('x:MtaOutboundThrottle'),
'x:MtaQueueQuota': limitsList('x:MtaQueueQuota'),
'x:Alert': (v) => <AlertsPage viewName={v} />,
'x:NetworkListener': (v) => <ListenersPage viewName={v} />,
'x:WebHook': (v) => <WebhooksPage viewName={v} />,
};
const unknown = (text: string) => (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">{text}</div>
);
function recordPage(viewName: string, objectName: string, permissionPrefix: string, id: string): ReactNode {
if (objectName === 'x:Trace') return <TraceDetailView viewName={viewName} objectId={id} />;
const page = useAccountStore.getState().hasObjectPermission(permissionPrefix, 'Update') ? (
<DynamicForm
viewName={viewName}
objectId={id}
intro={objectName === 'x:WebHook' ? <WebhookTestCard webhookId={id} /> : undefined}
/>
) : (
<DynamicViewPage viewName={viewName} objectId={id} />
);
// 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(<TenantLegacyProtocols tenantId={id} />, <SharingSwitches tenantId={id} />, page);
}
// A person's page says whether they're on hold or locked.
if (viewName === 'x:Account/User') {
return stacked(<HeldNotice accountId={id} />, <AccountLockBanner accountId={id} />, 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 <LoadingFallback />;
case 'dashboard':
return <DashboardView dashboardId={route.dashboardId} section={section ?? ''} />;
case 'wizard': {
if (route.name === 'dns' && route.param) return <ConnectDnsPage domainId={route.param} />;
const Wizard = WIZARDS[route.name];
return Wizard ? <Wizard /> : unknown(`Unknown guide: ${route.name}`);
}
case 'custom': {
const Page = CUSTOM_PAGES[route.name];
return Page ? <Page /> : unknown(`Unknown component: ${route.name}`);
}
case 'schema-loading':
return <div className="flex items-center justify-center p-8 text-muted-foreground">Loading...</div>;
case 'unknown':
return <div className="flex items-center justify-center p-8 text-destructive">Unknown view: {view}</div>;
case 'action':
return <ActionPage viewName={view} />;
case 'singleton':
return (SINGLETON_PAGES[route.objectName] ?? settingsForm)(view);
case 'create':
return <DynamicForm viewName={view} objectId={null} />;
case 'record':
return recordPage(view, route.objectName, route.permissionPrefix, route.id);
case 'list':
return LIST_PAGES[view]?.(view) ?? <DynamicList viewName={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 <Suspense fallback={<LoadingFallback />}>{renderView(schema, viewName, id, section)}</Suspense>;
}
function renderView(schema: Schema | null, viewName?: string, id?: string, section?: string): ReactNode {
if (!viewName) {
return <LoadingFallback />;
}
if (viewName.startsWith('Dashboard/')) {
const dashboardId = viewName.slice('Dashboard/'.length);
return <DashboardView dashboardId={dashboardId} section={section ?? ''} />;
}
// 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 <ConnectDnsPage domainId={param} />;
}
// inbuxa: guided setup, sending and receiving limits (settings-reorg).
if (wizard === 'limits') {
return <LimitsSetupPage />;
}
// inbuxa: guided setup, connect a sign-in directory (settings-reorg).
if (wizard === 'directory') {
return <DirectorySetupPage />;
}
// inbuxa: guided setup, how this server sends mail (settings-reorg).
if (wizard === 'sending') {
return <SendingSetupPage />;
}
// inbuxa: guided setup, automatic certificates (settings-reorg).
if (wizard === 'certificates') {
return <CertificateSetupPage />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown guide: {wizard}
</div>
);
}
if (viewName.startsWith('CustomComponent/')) {
const componentName = viewName.slice('CustomComponent/'.length);
if (componentName === 'Dashboard') {
const firstId = schema?.dashboards?.[0]?.id ?? 'overview';
return <DashboardView dashboardId={firstId} section={section ?? ''} />;
}
if (componentName === 'LiveDelivery') {
return <DeliveryTracePage />;
}
if (componentName === 'LiveTracing') {
return <LiveTracingPage />;
}
// INBUXA: Settings › Security › Hardening (legacy-protocols spec).
// inbuxa: the security to-do list, which holds the legacy-protocols switch
if (componentName === 'LegacyProtocols') {
return <SecurityPage />;
}
// inbuxa: Settings › Overview (settings-reorg).
if (componentName === 'SettingsOverview') {
return <SettingsOverviewPage />;
}
if (componentName === 'LocalAi') {
return <LocalAiPage />;
}
if (componentName === 'AuditLog') {
return <AuditLogPage />;
}
if (componentName === 'AccountLocks') {
return <AccountLocksPage />;
}
if (componentName === 'SharedMailboxes') {
return <SharedMailboxesPage />;
}
if (componentName === 'Deliverability') {
return <DeliverabilityPage />;
}
if (componentName === 'ScheduledReports') {
return <ScheduledReportsPage />;
}
if (componentName === 'LegalHolds') {
return <LegalHoldsPage />;
}
if (componentName === 'ComplianceOverview') {
return <ComplianceOverviewPage />;
}
if (componentName === 'DlpRules') {
return <DlpRulesPage />;
}
if (componentName === 'MailFlowRules') {
return <MailFlowRulesPage />;
}
if (componentName === 'Journal') {
return <JournalPage />;
}
if (componentName === 'HeldMail') {
return <HeldMailPage />;
}
if (componentName === 'DataInventory') {
return <DataInventoryPage />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName}
</div>
);
}
if (!schema) {
return <div className="flex items-center justify-center p-8 text-muted-foreground">Loading...</div>;
}
const resolved = resolveObject(schema, viewName);
if (!resolved) {
return <div className="flex items-center justify-center p-8 text-destructive">Unknown view: {viewName}</div>;
}
if (resolved.objectName === 'x:Action') {
return <ActionPage viewName={viewName} />;
}
if (resolved.objectType.type === 'singleton') {
// INBUXA: the Security settings carry the legacy protocols banner (LP-18).
if (resolved.objectName === 'x:Authentication') {
return (
<div className="space-y-4">
<DirectorySetupCard className="mx-auto max-w-4xl" />
<DynamicForm viewName={viewName} objectId="singleton" />
</div>
);
}
// inbuxa: Sender checks open with a choice of level (settings-reorg).
if (resolved.objectName === 'x:SenderAuth') {
return <SenderChecksPage viewName={viewName} />;
}
// inbuxa: every retention page opens with all the "how long"s as sentences (settings-reorg).
if (resolved.objectName === 'x:DataRetention') {
return <RetentionPage viewName={viewName} />;
}
// 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 <ReportsPage viewName={viewName} />;
}
// inbuxa: the metrics pages open with where the numbers go (settings-reorg).
if (resolved.objectName === 'x:Metrics') {
return <MetricsPage viewName={viewName} />;
}
// inbuxa: Spam filter › General opens with how hard the filter is (settings-reorg).
if (resolved.objectName === 'x:SpamSettings') {
return <SpamSettingsPage viewName={viewName} />;
}
// inbuxa: Sharing opens with who may share (MA-C)
if (resolved.objectName === 'x:Sharing') {
return (
<div className="space-y-4">
<SharingSwitches />
<DynamicForm viewName={viewName} objectId="singleton" />
</div>
);
}
if (resolved.objectName === 'x:Security') {
return (
<div className="space-y-4">
<LegacyProtocolsBanner />
<DynamicForm viewName={viewName} objectId="singleton" />
</div>
);
}
// inbuxa: the sending guide, offered where outbound delivery is set (settings-reorg).
if (resolved.objectName === 'x:MtaOutboundStrategy') {
return (
<div className="space-y-4">
<SendingSetupCard className="mx-auto max-w-4xl" />
<DynamicForm viewName={viewName} objectId="singleton" />
</div>
);
}
return <DynamicForm viewName={viewName} objectId="singleton" />;
}
if (id === 'new') {
return <DynamicForm viewName={viewName} objectId={null} />;
}
if (id) {
if (resolved.objectName === 'x:Trace') {
return <TraceDetailView viewName={viewName} objectId={id} />;
}
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
const page = canUpdate ? (
<DynamicForm
viewName={viewName}
objectId={id}
intro={resolved.objectName === 'x:WebHook' ? <WebhookTestCard webhookId={id} /> : undefined}
/>
) : (
<DynamicViewPage viewName={viewName} objectId={id} />
);
// 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 (
<div className="space-y-4">
<TenantLegacyProtocols tenantId={id} />
<SharingSwitches tenantId={id} />
{page}
</div>
);
}
// INBUXA: a person's page says whether it is locked (AL-1)
if (viewName === 'x:Account/User') {
return (
<div className="space-y-4">
<HeldNotice accountId={id} />
<AccountLockBanner accountId={id} />
{page}
</div>
);
}
return page;
}
if (viewName === 'x:Certificate' || viewName === 'x:AcmeProvider') {
return (
<div className="space-y-4">
<CertificateSetupCard />
<DynamicList viewName={viewName} />
</div>
);
}
// 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 (
<div className="space-y-4">
{viewName === 'x:DmarcExternalReport' ? <DmarcSummaryCard /> : <TlsSummaryCard />}
<DynamicList viewName={viewName} />
</div>
);
}
if (viewName === 'x:MtaRoute') {
return (
<div className="space-y-4">
<SendingSetupCard />
<DynamicList viewName={viewName} />
</div>
);
}
if (viewName === 'x:Directory') {
return (
<div className="space-y-4">
<DirectorySetupCard />
<DynamicList viewName={viewName} />
</div>
);
}
if (viewName === 'x:MtaInboundThrottle' || viewName === 'x:MtaOutboundThrottle' || viewName === 'x:MtaQueueQuota') {
return (
<div className="space-y-4">
<LimitsSummary object={viewName} />
<DynamicList viewName={viewName} />
</div>
);
}
if (viewName === 'x:Alert') {
return <AlertsPage viewName={viewName} />;
}
if (viewName === 'x:NetworkListener') {
return <ListenersPage viewName={viewName} />;
}
if (viewName === 'x:WebHook') {
return <WebhooksPage viewName={viewName} />;
}
return <DynamicList viewName={viewName} />;
return <Suspense fallback={<LoadingFallback />}>{pageFor(schema, viewName, id, section)}</Suspense>;
}
+172 -251
View File
@@ -1,20 +1,16 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<NavPlace | null>(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 (
<Collapsible open={open} onOpenChange={setOpen}>
@@ -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<typeof useNavigate>;
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 (
<Button
variant="ghost"
ref={isActive ? activeItemRef : undefined}
ref={active ? activeRef : undefined}
className={cn(
'relative h-8 w-full justify-start gap-2 rounded-lg px-3 text-[13px] font-normal text-muted-foreground hover:bg-muted hover:text-foreground',
isActive && 'bg-accent font-medium text-accent-foreground hover:bg-accent hover:text-accent-foreground',
active && 'bg-accent font-medium text-accent-foreground hover:bg-accent hover:text-accent-foreground',
)}
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
style={indent(depth)}
onClick={() => {
if (!isLocked) navigate(path);
if (state === 'open') go(resolveViewPath(section, item.viewName));
}}
>
<span className="truncate">{item.name || 'Overview'}</span>
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
{state === 'locked' && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
</Button>
);
}
if (item.type === 'container') {
if (!subtreeHasVisibleLink(item.items, edition)) return null;
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return (
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="h-8 w-full justify-start gap-1.5 rounded-lg px-3 text-[13px] font-normal text-muted-foreground hover:bg-muted hover:text-foreground"
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
>
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
<span className="truncate">{item.name}</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{item.items.map((sub) => (
<SidebarSubItem
key={sub.type === 'link' ? sub.viewName : sub.name}
item={sub}
depth={depth + 1}
sectionName={sectionName}
currentPath={currentPath}
navigate={navigate}
edition={edition}
activeItemRef={activeItemRef}
/>
))}
</CollapsibleContent>
</AutoOpenCollapsible>
);
}
return null;
if (!subtreeHasVisibleLink(item.items, edition)) return null;
return (
<Group holdsActive={subtreeContainsActive(item.items, path, section)}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="h-8 w-full justify-start gap-1.5 rounded-lg px-3 text-[13px] font-normal text-muted-foreground hover:bg-muted hover:text-foreground"
style={indent(depth)}
>
<ChevronDown className={cn('h-3 w-3', CHEVRON)} />
<span className="truncate">{item.name}</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{item.items.map((sub) => (
<SubItem key={subKey(sub)} item={sub} depth={depth + 1} />
))}
</CollapsibleContent>
</Group>
);
}
interface SidebarTopItemProps {
item: LayoutItem;
sectionName: string;
currentPath: string;
navigate: ReturnType<typeof useNavigate>;
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 (
<Button
variant="ghost"
ref={isActive ? activeItemRef : undefined}
className={cn(
'h-10 w-full justify-start gap-3 rounded-xl px-2 font-medium text-foreground/85 hover:bg-muted hover:text-foreground',
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
)}
ref={active ? activeRef : undefined}
className={cn(TOP, active && 'bg-accent text-accent-foreground hover:bg-accent')}
onClick={() => {
if (!isLocked) navigate(path);
if (state === 'open') go(resolveViewPath(section, viewName));
}}
>
<IconTile name={icon} />
<span className="truncate">{name}</span>
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
{state === 'locked' && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
</Button>
);
}
if ('container' in item) {
const { name, icon, items } = item.container;
if (!subtreeHasVisibleLink(items, edition)) return null;
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
'h-10 w-full justify-start gap-3 rounded-xl px-2 font-medium text-foreground/85 hover:bg-muted hover:text-foreground',
containsActive && 'text-foreground',
)}
>
<IconTile name={icon} />
<span className="truncate">{name}</span>
<ChevronDown className="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="ml-[1.35rem] mt-0.5 mb-1 space-y-0.5 border-l border-border pl-2.5">
{items.map((sub) => (
<SidebarSubItem
key={sub.type === 'link' ? sub.viewName : sub.name}
item={sub}
depth={1}
sectionName={sectionName}
currentPath={currentPath}
navigate={navigate}
edition={edition}
activeItemRef={activeItemRef}
/>
))}
</CollapsibleContent>
</AutoOpenCollapsible>
);
}
return null;
const { name, icon, items } = item.container;
if (!subtreeHasVisibleLink(items, edition)) return null;
const holdsActive = subtreeContainsActive(items, path, section);
return (
<Group holdsActive={holdsActive}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className={cn(TOP, holdsActive && 'text-foreground')}>
<IconTile name={icon} />
<span className="truncate">{name}</span>
<ChevronDown className={cn('ml-auto h-3.5 w-3.5 text-muted-foreground', CHEVRON)} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="ml-[1.35rem] mt-0.5 mb-1 space-y-0.5 border-l border-border pl-2.5">
{items.map((sub) => (
<SubItem key={subKey(sub)} item={sub} depth={1} />
))}
</CollapsibleContent>
</Group>
);
}
/** 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<typeof useNavigate>;
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 (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={name}
aria-current={isActive ? 'page' : undefined}
className={cn(base, isActive && 'bg-accent')}
onClick={() => navigate(resolveViewPath(sectionName, viewName))}
aria-current={active ? 'page' : undefined}
className={cn(RAIL, active && 'bg-accent')}
onClick={() => go(resolveViewPath(section, viewName))}
>
<IconTile name={icon} />
</button>
@@ -265,16 +197,19 @@ function RailItem({
</Tooltip>
);
}
const { name, icon, items } = item.container;
if (!subtreeHasVisibleLink(items, edition)) return null;
const links = visibleLinks(items, edition);
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button type="button" aria-label={name} className={cn(base, containsActive && 'bg-accent')}>
<button
type="button"
aria-label={name}
className={cn(RAIL, subtreeContainsActive(items, path, section) && 'bg-accent')}
>
<IconTile name={icon} />
</button>
</DropdownMenuTrigger>
@@ -286,13 +221,13 @@ function RailItem({
<IconTile name={icon} size="sm" />
{name}
</DropdownMenuLabel>
{links.map((l) => (
{visibleLinks(items, edition).map((link) => (
<DropdownMenuItem
key={l.viewName}
className={cn(pathMatchesView(currentPath, sectionName, l.viewName) && 'bg-accent text-accent-foreground')}
onClick={() => 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}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -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<HTMLButtonElement | null>(null);
const activeItemRef = useCallback<ActiveItemRef>((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 (
<TooltipProvider delayDuration={150}>
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-[4.5rem] flex-col border-r bg-background">
<div className="flex justify-center border-b py-2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Expand sidebar"
onClick={toggleSidebarCollapsed}
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar</TooltipContent>
</Tooltip>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto py-3 [scrollbar-width:none]">
{layout.items.map((item) => (
<RailItem
key={'link' in item ? item.link.viewName : item.container.name}
item={item}
sectionName={layout.name}
currentPath={location.pathname}
navigate={navigate}
edition={edition}
/>
))}
</nav>
</aside>
</TooltipProvider>
<Place.Provider value={place}>
<TooltipProvider delayDuration={150}>
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-[4.5rem] flex-col border-r bg-background">
<div className="flex justify-center border-b py-2">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Expand sidebar"
onClick={toggleSidebarCollapsed}
className="flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
>
<PanelLeftOpen className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar</TooltipContent>
</Tooltip>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto py-3 [scrollbar-width:none]">
{layout.items.map((item) => (
<RailItem key={topItemKey(item)} item={item} />
))}
</nav>
</aside>
</TooltipProvider>
</Place.Provider>
);
}
return (
<>
<Place.Provider value={place}>
<div
aria-hidden="true"
className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden"
@@ -422,19 +351,11 @@ export function Sidebar({ mobileOnly = false }: SidebarProps = {}) {
<div className="flex-1 overflow-y-auto py-2 [scrollbar-width:thin]">
<nav className="flex flex-col gap-0.5 px-2">
{layout.items.map((item) => (
<SidebarTopItem
key={'link' in item ? item.link.viewName : item.container.name}
item={item}
sectionName={layout.name}
currentPath={location.pathname}
navigate={navigate}
edition={edition}
activeItemRef={activeItemRef}
/>
<TopItem key={topItemKey(item)} item={item} />
))}
</nav>
</div>
</aside>
</>
</Place.Provider>
);
}
+244 -239
View File
@@ -1,21 +1,30 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<string, unknown>)[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 (
<TooltipProvider delayDuration={150}>
<div
className="hidden items-center gap-0.5 rounded-xl bg-muted p-1 sm:flex"
role="tablist"
aria-label={t('sections', 'Sections')}
>
{sections.map((layout) => {
const active = layout.name === activeSection;
return (
<Tooltip key={layout.name}>
<TooltipTrigger asChild>
<button
type="button"
role="tab"
aria-selected={active}
aria-label={layout.name}
onClick={() => open(layout)}
className={cn(
'flex h-8 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground lg:px-3',
active && 'bg-card text-primary shadow-soft',
)}
>
{createElement(iconByName(layout.icon), { className: 'h-4 w-4 shrink-0' })}
<span className="hidden lg:inline">{layout.name}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{layout.name}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
);
}
/** 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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}>
<User className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{sections.length > 0 && (
<>
<DropdownMenuLabel>{t('sections', 'Sections')}</DropdownMenuLabel>
<DropdownMenuGroup>
{sections.map((layout) => {
const Icon = iconByName(layout.icon);
return (
<DropdownMenuItem key={layout.name} onClick={() => open(layout)}>
<Icon className="mr-2 h-4 w-4" />
{layout.name}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
{Object.keys(accounts).length > 0 && (
<>
<DropdownMenuLabel>{t('accounts', 'Accounts')}</DropdownMenuLabel>
<DropdownMenuGroup>
{Object.entries(accounts).map(([id, info]) => (
<DropdownMenuItem key={id} onClick={() => switchAccount(id)}>
{id === activeAccountId && <Check className="mr-2 h-4 w-4" />}
<span className={id !== activeAccountId ? 'ml-6' : ''}>{info.name || id}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<LayoutTemplate className="mr-2 h-4 w-4" />
{t('nav.layoutMenu', 'Layout')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuRadioGroup value={adminLayout} onValueChange={(v) => isAdminLayout(v) && setAdminLayout(v)}>
<DropdownMenuRadioItem value="modern" className="gap-2">
{t('nav.layoutModern', 'Modern')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="legacy" className="gap-2">
{t('nav.layoutLegacy', 'Legacy')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<p className="px-2 py-1.5 text-[11px] leading-snug text-muted-foreground">
{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.')}
</p>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="mr-2 h-4 w-4" />
{t('theme.menu', 'Theme')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
<DropdownMenuRadioGroup value={palette} onValueChange={(v) => isPaletteId(v) && setPalette(v)}>
{PALETTES.map((p) => (
<DropdownMenuRadioItem key={p.id} value={p.id} className="gap-2">
<span
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 rounded-full ring-1 ring-black/10 dark:ring-white/15"
style={{ background: theme === 'dark' ? p.swatch[1] : p.swatch[0] }}
/>
<span className="notranslate" translate="no">
{p.id === 'default' ? t('theme.classic', 'Classic') : p.name}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Accessibility className="mr-2 h-4 w-4" />
{t('a11y.menu', 'Accessibility')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-60">
<DropdownMenuLabel>{t('a11y.contrast', 'Contrast')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={contrast} onValueChange={(v) => isContrastPref(v) && setContrast(v)}>
<DropdownMenuRadioItem value="system">{followSystem}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="standard">{t('a11y.contrastStandard', 'Standard')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="high">{t('a11y.contrastHigh', 'High')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('a11y.motion', 'Motion')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={motion} onValueChange={(v) => isMotionPref(v) && setMotion(v)}>
<DropdownMenuRadioItem value="system">{followSystem}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="reduce">{t('a11y.motionReduce', 'Reduce')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="full">{t('a11y.motionFull', 'Full')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.dispatchEvent(new Event(OPEN_SHORTCUTS))}>
<Keyboard className="mr-2 h-4 w-4" />
{t('a11y.shortcuts', 'Keyboard shortcuts')}
<span aria-hidden="true" className="ml-auto font-mono text-xs text-muted-foreground">
?
</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
<FileCode className="mr-2 h-4 w-4" />
{t('source.menu', 'Source code (AGPL-3.0)')}
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut(navigate)}>
<LogOut className="mr-2 h-4 w-4" />
{t('logout', 'Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
/** 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 (
<header className="sticky top-0 z-40 flex h-14 items-center gap-4 border-b bg-background px-4">
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="md:hidden">
@@ -147,205 +344,13 @@ export function TopBar() {
</Button>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
{/* INBUXA: the three areas, one click away, where the eye already looks. */}
{schema && navigableLayouts.length > 1 && (
<TooltipProvider delayDuration={150}>
<div
className="hidden items-center gap-0.5 rounded-xl bg-muted p-1 sm:flex"
role="tablist"
aria-label={t('sections', 'Sections')}
>
{navigableLayouts.map((layout) => {
const isActive = layout.name === activeSection;
return (
<Tooltip key={layout.name}>
<TooltipTrigger asChild>
<button
type="button"
role="tab"
aria-selected={isActive}
aria-label={layout.name}
onClick={() => {
setActiveSection(layout.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const firstLink = sectionLandingLink(schema, layout, edition, canGet, hasPermission);
if (firstLink) navigate(`/${layout.name}/${firstLink}`);
}}
className={cn(
'flex h-8 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] font-medium text-muted-foreground transition-colors hover:text-foreground lg:px-3',
isActive && 'bg-card text-primary shadow-soft',
)}
>
{createElement(getIcon(layout.icon), { className: 'h-4 w-4 shrink-0' })}
<span className="hidden lg:inline">{layout.name}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{layout.name}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
)}
<SectionTabs />
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}>
<User className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{schema && navigableLayouts.length > 0 && (
<>
<DropdownMenuLabel>{t('sections', 'Sections')}</DropdownMenuLabel>
<DropdownMenuGroup>
{navigableLayouts.map((layout) => {
const Icon = getIcon(layout.icon);
return (
<DropdownMenuItem
key={layout.name}
onClick={() => {
setActiveSection(layout.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const firstLink = sectionLandingLink(schema, layout, edition, canGet, hasPermission);
if (firstLink) {
navigate(`/${layout.name}/${firstLink}`);
}
}}
>
<Icon className="mr-2 h-4 w-4" />
{layout.name}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
{Object.keys(accounts).length > 0 && (
<>
<DropdownMenuLabel>{t('accounts', 'Accounts')}</DropdownMenuLabel>
<DropdownMenuGroup>
{Object.entries(accounts).map(([id, info]) => (
<DropdownMenuItem key={id} onClick={() => switchAccount(id)}>
{id === activeAccountId && <Check className="mr-2 h-4 w-4" />}
<span className={id !== activeAccountId ? 'ml-6' : ''}>{info.name || id}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
{/* INBUXA: the shell is the reader's choice, the way the palette is. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<LayoutTemplate className="mr-2 h-4 w-4" />
{t('nav.layoutMenu', 'Layout')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-56">
<DropdownMenuRadioGroup
value={adminLayout}
onValueChange={(v) => isAdminLayout(v) && setAdminLayout(v)}
>
<DropdownMenuRadioItem value="modern" className="gap-2">
{t('nav.layoutModern', 'Modern')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="legacy" className="gap-2">
{t('nav.layoutLegacy', 'Legacy')}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<p className="px-2 py-1.5 text-[11px] leading-snug text-muted-foreground">
{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.')}
</p>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* INBUXA: the same palettes as INBUXA webmail. */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Palette className="mr-2 h-4 w-4" />
{t('theme.menu', 'Theme')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-52">
<DropdownMenuRadioGroup value={palette} onValueChange={(v) => isPaletteId(v) && setPalette(v)}>
{PALETTES.map((p) => (
<DropdownMenuRadioItem key={p.id} value={p.id} className="gap-2">
<span
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0 rounded-full ring-1 ring-black/10 dark:ring-white/15"
style={{ background: theme === 'dark' ? p.swatch[1] : p.swatch[0] }}
/>
<span className="notranslate" translate="no">
{p.id === 'default' ? t('theme.classic', 'Classic') : p.name}
</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* inbuxa: contrast and motion, over any theme (roadmap item 13) */}
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Accessibility className="mr-2 h-4 w-4" />
{t('a11y.menu', 'Accessibility')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-60">
<DropdownMenuLabel>{t('a11y.contrast', 'Contrast')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={contrast} onValueChange={(v) => isContrastPref(v) && setContrast(v)}>
<DropdownMenuRadioItem value="system">
{t('a11y.followSystem', 'Follow the system')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="standard">
{t('a11y.contrastStandard', 'Standard')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="high">{t('a11y.contrastHigh', 'High')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('a11y.motion', 'Motion')}</DropdownMenuLabel>
<DropdownMenuRadioGroup value={motion} onValueChange={(v) => isMotionPref(v) && setMotion(v)}>
<DropdownMenuRadioItem value="system">
{t('a11y.followSystem', 'Follow the system')}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="reduce">{t('a11y.motionReduce', 'Reduce')}</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="full">{t('a11y.motionFull', 'Full')}</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => window.dispatchEvent(new Event(OPEN_SHORTCUTS))}>
<Keyboard className="mr-2 h-4 w-4" />
{t('a11y.shortcuts', 'Keyboard shortcuts')}
<span aria-hidden="true" className="ml-auto font-mono text-xs text-muted-foreground">
?
</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href={SOURCE_URL} target="_blank" rel="noopener noreferrer">
<FileCode className="mr-2 h-4 w-4" />
{t('source.menu', 'Source code (AGPL-3.0)')}
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut(navigate)}>
<LogOut className="mr-2 h-4 w-4" />
{t('logout', 'Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AccountMenu />
</div>
</header>
);
+65
View File
@@ -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' });
});
});
+48
View File
@@ -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/<name>[/<param>]`. */
| { kind: 'wizard'; name: string; param?: string }
/** One of the console's own pages: `CustomComponent/<name>`. */
| { 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' };
}
+21
View File
@@ -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);
});
});
+16
View File
@@ -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<string, unknown>)[pascal] as LucideIcons.LucideIcon | undefined) ?? LucideIcons.Circle;
}
+101 -228
View File
@@ -1,159 +1,99 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<string, PageRule> = {
'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<string> {
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));
}
+186
View File
@@ -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([]);
});
});
+97 -2
View File
@@ -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);
});
});
+44 -65
View File
@@ -1,27 +1,25 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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. */
+31
View File
@@ -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<Record<'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey', boolean>> = {}) => ({
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);
});
});
+7
View File
@@ -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<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'shiftKey' | 'altKey' | 'key'>,
): 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;
+87 -225
View File
@@ -1,92 +1,71 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<string | null>(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<string, { name: string; isPersonal: boolean }> = {};
for (const [accountId, info] of Object.entries(
session.accounts as Record<string, { name: string; isPersonal: boolean }>,
)) {
accounts[accountId] = { name: info.name, isPersonal: info.isPersonal };
}
const primaryAccounts = session.primaryAccounts as Record<string, string>;
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<string, Record<string, unknown>> | 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 <LoadingFallback fullScreen />;
}
if (initializing) return <LoadingFallback fullScreen />;
if (initError) {
return (
@@ -327,7 +190,6 @@ export default function AdminPanel() {
return (
<div className="flex min-h-screen flex-col">
{/* inbuxa: keyboard users can jump past the menus (roadmap item 13) */}
<a href="#main" className="skip-link">
{t('a11y.skip', 'Skip to content')}
</a>
+232
View File
@@ -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<string, string>) =>
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<string, string>();
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',
});
});
});
+185
View File
@@ -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<string, { name: string; isPersonal: boolean }>;
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<string, unknown>): SessionInfo {
const accounts = Object.fromEntries(
Object.entries(session.accounts as Record<string, { name: string; isPersonal: boolean }>).map(
([id, { name, isPersonal }]) => [id, { name, isPersonal }],
),
);
const primary = session.primaryAccounts as Record<string, string>;
const core = (session.capabilities as Record<string, Record<string, unknown>> | 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();
}