Rewrite the panel, sidebar, top bar, page routing and layout rules #132
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user