Rewrite the app's entry and leaf components; drop the empty upsell #122

Merged
jcoffey-dev merged 1 commits from phase2/leaves into main 2026-10-19 00:54:23 +00:00
17 changed files with 175 additions and 236 deletions

No files matched your search

+5 -3
View File
@@ -1,15 +1,17 @@
/*
* 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
*/
import { Suspense } from 'react';
import { Outlet } from 'react-router-dom';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { Toaster } from '@/components/ui/toaster';
/** Every route renders inside this: a crash screen, a loading screen while pages load, and toasts. */
export default function App() {
return (
<ErrorBoundary>
@@ -1,24 +0,0 @@
/*
* 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.
*/
interface EnterpriseUpsellProps {
open: boolean;
onClose: () => void;
}
/**
* INBUXA: nothing to sell. There is one edition, every feature is in it, and
* the edition is never anything but complete (see accountStore), so this
* never opens. It stays as an empty component so the places upstream calls
* it from merge without conflicts.
*/
export function EnterpriseUpsell({ open }: EnterpriseUpsellProps) {
void open;
return null;
}
+6 -7
View File
@@ -1,24 +1,23 @@
/*
* 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 { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import inbuxaMark from '@/assets/inbuxa-mark.png';
import inbuxaMark from '@/assets/inbuxa-mark.png';
import { cn } from '@/lib/utils';
/** The bouncing cat and "Loading…", in a page's place or filling the screen. */
export function LoadingFallback({ fullScreen = false }: { fullScreen?: boolean }) {
const { t } = useTranslation();
return (
<div className={cn('flex flex-col items-center justify-center gap-3', fullScreen ? 'min-h-screen' : 'p-8')}>
<img src={inbuxaMark} alt="" className="h-12 w-auto animate-bounce [animation-duration:1.4s]" />
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<Loader2 className="size-4 animate-spin text-primary" />
{t('common.loading')}
</p>
</div>
+15 -18
View File
@@ -1,16 +1,14 @@
/*
* 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 { useEffect, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { getLogoState, loadLogoOnce, subscribeToLogo } from '@/lib/logoCache';
import inbuxaMark from '@/assets/inbuxa-mark.png';
import { getLogoState, loadLogoOnce, subscribeToLogo } from '@/lib/logoCache';
export function DefaultLogo() {
const { t } = useTranslation();
@@ -58,21 +56,20 @@ export function DefaultLogo() {
);
}
/**
* The server's own logo if an admin set one, else inbuxa's. Until the server
* answers, an empty space the logo's size holds the layout still.
*/
export default function Logo() {
const { t } = useTranslation();
const logo = useSyncExternalStore(subscribeToLogo, getLogoState, getLogoState);
useEffect(() => {
loadLogoOnce();
}, []);
if (logo.status === 'custom') {
return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
useEffect(loadLogoOnce, []);
switch (logo.status) {
case 'custom':
return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
case 'loading':
return <span aria-hidden="true" className="block h-7 w-[140px]" />;
default:
return <DefaultLogo />;
}
if (logo.status === 'loading') {
return <span className="block h-7 w-[140px]" aria-hidden="true" />;
}
return <DefaultLogo />;
}
+8 -5
View File
@@ -1,18 +1,21 @@
/*
* 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
*/
import { createContext, useContext } from 'react';
import { useAccountStore } from '@/stores/accountStore';
export type EditionValue = 'enterprise' | 'community' | 'oss';
/** Lets a form show fields as a given edition would (the setup wizard does). */
export const FormEditionContext = createContext<EditionValue | null>(null);
/** The edition a form shows fields for: the nearest FormEditionContext, else the server's. */
export function useEffectiveEdition(): EditionValue {
const override = useContext(FormEditionContext);
const storeEdition = useAccountStore((s) => s.edition);
return override ?? storeEdition;
const fromForm = useContext(FormEditionContext);
const fromServer = useAccountStore((s) => s.edition);
return fromForm ?? fromServer;
}
+20 -22
View File
@@ -1,49 +1,47 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { Bug } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast';
import { openInSievepad } from '@/lib/sievepad';
import type { SieveInterpreter } from '@/lib/sieveLimits';
import { openInSievepad } from '@/lib/sievepad';
interface SievepadButtonProps {
/**
* Debug: opens a copy of the script in the Sieve playground that ships with
* the console, under the limits of the interpreter that runs it. Nothing
* leaves the console, so there is nothing to warn about first.
*/
export function SievepadButton({
scriptName,
source,
interpreter,
}: {
scriptName: string;
source: string;
interpreter: SieveInterpreter;
}
// inbuxa: the playground ships with the console, so the script goes nowhere
// else and there is nothing to warn about first. It runs under the limits
// of the interpreter that runs it on the server.
export function SievepadButton({ scriptName, source, interpreter }: SievepadButtonProps) {
}) {
const { t } = useTranslation();
const open = () => {
openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source, interpreter).catch(() => {
toast({ title: t('sievepad.failed', "Couldn't open the Sieve playground."), variant: 'destructive' });
});
};
const debug = () =>
openInSievepad(scriptName || t('sievepad.defaultName', 'Sieve script'), source, interpreter).catch(() =>
toast({ title: t('sievepad.failed', "Couldn't open the Sieve playground."), variant: 'destructive' }),
);
return (
<div className="flex justify-end">
<Button
type="button"
variant="outline"
size="sm"
onClick={open}
disabled={!source.trim()}
title={t('sievepad.debugHint', 'Test a copy of this script in the Sieve playground')}
onClick={debug}
>
<Bug className="h-4 w-4" />
<Bug className="size-4" />
{t('sievepad.debug', 'Debug')}
</Button>
</div>
+14 -11
View File
@@ -1,19 +1,22 @@
/*
* 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
*/
import type { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const authenticated = useAuthStore((s) => s.isAuthenticated());
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
const location = useLocation();
if (!authenticated && !bypassToken) {
const originalPath = location.pathname + location.search;
return <Navigate to="/login" replace state={{ from: originalPath }} />;
}
return <>{children}</>;
/**
* Shows its children to a signed-in admin; anyone else goes to the sign-in
* page, which brings them back here afterwards. A development build with
* VITE_ACCESS_TOKEN set counts as signed in.
*/
export function ProtectedRoute({ children }: { children: ReactNode }) {
const signedIn = useAuthStore((s) => s.isAuthenticated()) || !!import.meta.env.VITE_ACCESS_TOKEN;
const { pathname, search } = useLocation();
if (signedIn) return children;
return <Navigate to="/login" replace state={{ from: pathname + search }} />;
}
+1 -8
View File
@@ -23,7 +23,6 @@ import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock, MoreHorizontal } = LucideIcons;
import { cn } from '@/lib/utils';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import {
DropdownMenu,
DropdownMenuContent,
@@ -216,7 +215,6 @@ export function SectionNav({ layout }: { layout: Layout }) {
const navigate = useNavigate();
const location = useLocation();
const edition = useAccountStore((s) => s.edition);
const [upsellOpen, setUpsellOpen] = useState(false);
const items = useMemo(() => layout.items.filter((item) => topItemVisible(item, edition)), [layout, edition]);
@@ -265,10 +263,7 @@ export function SectionNav({ layout }: { layout: Layout }) {
const onPick = useCallback(
(viewName: string, locked: boolean) => {
if (locked) {
setUpsellOpen(true);
return;
}
if (locked) return;
navigate(resolveViewPath(layout.name, viewName));
},
[navigate, layout.name],
@@ -358,8 +353,6 @@ export function SectionNav({ layout }: { layout: Layout }) {
</DropdownMenuContent>
</DropdownMenu>
)}
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
</nav>
);
}
+3 -29
View File
@@ -13,7 +13,6 @@ import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock, PanelLeftClose, PanelLeftOpen } = LucideIcons;
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { IconTile } from '@/components/common/IconTile';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
@@ -67,7 +66,6 @@ interface SidebarSubItemProps {
currentPath: string;
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
activeItemRef: ActiveItemRef;
}
@@ -78,7 +76,6 @@ function SidebarSubItem({
currentPath,
navigate,
edition,
onUpsell,
activeItemRef,
}: SidebarSubItemProps) {
if (item.type === 'link') {
@@ -102,11 +99,7 @@ function SidebarSubItem({
)}
style={depth > 1 ? { paddingLeft: `${(depth - 1) * 12 + 12}px` } : undefined}
onClick={() => {
if (isLocked) {
onUpsell();
} else {
navigate(path);
}
if (!isLocked) navigate(path);
}}
>
<span className="truncate">{item.name || 'Overview'}</span>
@@ -141,7 +134,6 @@ function SidebarSubItem({
currentPath={currentPath}
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
activeItemRef={activeItemRef}
/>
))}
@@ -159,19 +151,10 @@ interface SidebarTopItemProps {
currentPath: string;
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
activeItemRef: ActiveItemRef;
}
function SidebarTopItem({
item,
sectionName,
currentPath,
navigate,
edition,
onUpsell,
activeItemRef,
}: SidebarTopItemProps) {
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, activeItemRef }: SidebarTopItemProps) {
if ('link' in item) {
const { name, icon, viewName } = item.link;
@@ -194,11 +177,7 @@ function SidebarTopItem({
isActive && 'bg-accent text-accent-foreground hover:bg-accent',
)}
onClick={() => {
if (isLocked) {
onUpsell();
} else {
navigate(path);
}
if (!isLocked) navigate(path);
}}
>
<IconTile name={icon} />
@@ -239,7 +218,6 @@ function SidebarTopItem({
currentPath={currentPath}
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
activeItemRef={activeItemRef}
/>
))}
@@ -343,7 +321,6 @@ 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 [upsellOpen, setUpsellOpen] = useState(false);
const activeItem = useRef<HTMLButtonElement | null>(null);
const activeItemRef = useCallback<ActiveItemRef>((el) => {
activeItem.current = el;
@@ -452,14 +429,11 @@ export function Sidebar({ mobileOnly = false }: SidebarProps = {}) {
currentPath={location.pathname}
navigate={navigate}
edition={edition}
onUpsell={() => setUpsellOpen(true)}
activeItemRef={activeItemRef}
/>
))}
</nav>
</div>
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
</aside>
</>
);
-4
View File
@@ -33,7 +33,6 @@ import {
import { isPaletteId, PALETTES } from '@/lib/palettes';
import Logo from '@/components/common/Logo';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { SOURCE_URL } from '@/lib/sourceDownload';
import { visibleLayouts } from '@/lib/layout';
import { sectionLandingLink } from '@/lib/lastVisited';
@@ -78,7 +77,6 @@ export function TopBar() {
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission);
const schema = useSchemaStore((s) => s.schema);
const [upsellOpen, setUpsellOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
@@ -138,8 +136,6 @@ export function TopBar() {
</div>
<div className="ml-auto flex items-center gap-2 md:ml-0">
{edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />}
<Button
variant="ghost"
size="icon"
+1 -4
View File
@@ -59,7 +59,6 @@ import {
} from '@/components/ui/alert-dialog';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
import { ObjectPicker } from '@/components/common/ObjectPicker';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { toast } from '@/hooks/use-toast';
import { deleteWithUndo } from '@/components/common/undoableDelete';
import { useShortcut } from '@/lib/shortcuts';
@@ -397,7 +396,6 @@ export function DynamicList({ viewName }: DynamicListProps) {
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const edition = useAccountStore((s) => s.edition);
const explainAvailable = useExplainStore((s) => s.available);
const [upsellOpen, setUpsellOpen] = useState(false);
const resolved = useMemo(() => {
if (!schema) return null;
@@ -1199,7 +1197,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
onClick={(e) => {
e.stopPropagation();
if (locked) {
setUpsellOpen(true);
return;
} else if (needsConfirmation) {
setConfirmAction({
label: action.label,
@@ -1617,7 +1615,6 @@ export function DynamicList({ viewName }: DynamicListProps) {
</AlertDialogContent>
</AlertDialog>
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
{directoryLocks.dialogs}
</div>
);
+20 -10
View File
@@ -1,27 +1,37 @@
/*
* 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
*/
/**
* The console's strings. It ships in English only (en.json); a browser asking
* for another language gets English. The admin's own locale still sets the
* reading direction.
*/
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './en.json';
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur'];
const RIGHT_TO_LEFT = new Set(['ar', 'fa', 'he', 'ur']);
i18n.use(initReactI18next).init({
/** "pt_BR" or "pt-BR" → "pt". */
const languageOf = (locale: string) => locale.split(/[-_]/)[0];
void i18n.use(initReactI18next).init({
resources: { en: { translation: en } },
lng: navigator.language?.split('-')[0] || 'en',
lng: languageOf(navigator.language ?? '') || 'en',
fallbackLng: 'en',
// React escapes what it renders.
interpolation: { escapeValue: false },
});
export function setLocale(locale: string) {
const lang = locale.split('_')[0].split('-')[0];
i18n.changeLanguage(lang);
const isRtl = RTL_LANGUAGES.includes(lang);
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
/** Switches to the signed-in account's locale. */
export function setLocale(locale: string): void {
const language = languageOf(locale);
void i18n.changeLanguage(language);
document.documentElement.dir = RIGHT_TO_LEFT.has(language) ? 'rtl' : 'ltr';
}
export default i18n;
+23 -22
View File
@@ -1,10 +1,14 @@
/*
* 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
*/
/*
* The console's colors, as custom properties for light and dark (`.dark` on
* <html>), handed to Tailwind as its color, radius, font and shadow tokens.
* Other palettes (palettes.css) and high contrast (a11y.css) override the
* same properties.
*/
@import 'tailwindcss';
@@ -101,62 +105,63 @@
--chart-5: #fbbf24;
}
/* Tailwind's tokens, read from the properties above so a palette or the dark theme changes them all. */
@theme inline {
/* Surfaces */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-content-background: var(--content-background);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
/* Emphasis */
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-highlight: var(--highlight);
--color-highlight-soft: var(--highlight-soft);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
/* Lines */
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* Charts */
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
/* Shape and type: radii step 2px around --radius. */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--shadow-soft: var(--shadow-soft);
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Space Grotesk Variable', 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--shadow-soft: var(--shadow-soft);
}
@layer base {
* {
@apply border-border;
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent) transparent;
}
body {
@apply bg-background text-foreground antialiased;
/* Inter's single-storey a and open digits. */
font-feature-settings: 'cv11', 'ss01';
}
@@ -170,13 +175,9 @@
::selection {
background: color-mix(in oklab, var(--primary) 30%, transparent);
}
* {
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--muted-foreground) 35%, transparent) transparent;
}
}
/* Dark spinner arrows on number fields. */
.dark input[type='number'] {
color-scheme: dark;
}
+36 -46
View File
@@ -1,53 +1,57 @@
/*
* 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 { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import './i18n';
import '@fontsource-variable/inter';
import '@fontsource-variable/space-grotesk';
import './index.css';
import App from './App';
import LoginPage from './pages/LoginPage';
import OAuthCallback from './pages/OAuthCallback';
import NotFound from './pages/NotFound';
import { AdminPanel } from './pages/AdminPanel.lazy';
import { ProtectedRoute } from './components/layout/ProtectedRoute';
import { getBasePath } from './lib/basePath';
import { loadLogoOnce } from './lib/logoCache';
import { AdminPanel } from './pages/AdminPanel.lazy';
import LoginPage from './pages/LoginPage';
import NotFound from './pages/NotFound';
import OAuthCallback from './pages/OAuthCallback';
(() => {
/**
* Applies the saved theme and palette (uiStore, persisted as `inbuxa-ui`)
* before React draws anything, so the page doesn't flash the wrong colors.
* With no saved theme, or "system", the system's preference decides.
*/
function applySavedLook() {
const root = document.documentElement;
let saved: { theme?: unknown; palette?: unknown } = {};
try {
const persisted = localStorage.getItem('inbuxa-ui');
if (persisted) {
const parsed = JSON.parse(persisted);
const theme = parsed?.state?.theme;
// INBUXA: the palette too, before the first paint, so it doesn't flash.
const palette = parsed?.state?.palette;
if (typeof palette === 'string' && palette !== 'ihasmail' && /^[a-z-]+$/.test(palette)) {
document.documentElement.dataset.palette = palette;
}
if (theme === 'dark' || theme === 'light') {
document.documentElement.classList.toggle('dark', theme === 'dark');
return;
}
}
// eslint-disable-next-line no-empty
} catch {}
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', !!prefersDark);
})();
saved = JSON.parse(localStorage.getItem('inbuxa-ui') ?? '{}')?.state ?? {};
} catch {
// Unreadable: use the defaults.
}
if (typeof saved.palette === 'string' && saved.palette !== 'ihasmail' && /^[a-z-]+$/.test(saved.palette)) {
root.dataset.palette = saved.palette;
}
const dark =
saved.theme === 'dark' || saved.theme === 'light'
? saved.theme === 'dark'
: !!window.matchMedia?.('(prefers-color-scheme: dark)').matches;
root.classList.toggle('dark', dark);
}
applySavedLook();
loadLogoOnce();
const basePath = getBasePath();
const signedIn = (
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
);
const router = createBrowserRouter(
[
@@ -58,26 +62,12 @@ const router = createBrowserRouter(
children: [
{ path: 'login', element: <LoginPage /> },
{ path: 'oauth/callback', element: <OAuthCallback /> },
{
path: ':section/*',
element: (
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
),
},
{
index: true,
element: (
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
),
},
{ index: true, element: signedIn },
{ path: ':section/*', element: signedIn },
],
},
],
{ basename: basePath },
{ basename: getBasePath() },
);
createRoot(document.getElementById('root')!).render(
+3 -2
View File
@@ -1,9 +1,10 @@
/*
* 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
*/
import { lazy } from 'react';
/** The signed-in console, loaded only once someone is signed in. */
export const AdminPanel = lazy(() => import('./AdminPanel'));
+14 -15
View File
@@ -1,27 +1,26 @@
/*
* 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
*/
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
/** A path no route matches. */
export default function NotFound() {
const { t } = useTranslation();
useDocumentTitle(t('errors.notFound'));
const message = t('errors.notFound');
useDocumentTitle(message);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">404</h1>
<p className="mt-2 text-muted-foreground">{t('errors.notFound')}</p>
<Link to="/" className="mt-4 inline-block text-primary underline hover:no-underline">
{t('common.back')}
</Link>
</div>
</div>
<main className="flex min-h-screen flex-col items-center justify-center text-center">
<h1 className="text-4xl font-bold">404</h1>
<p className="mt-2 text-muted-foreground">{message}</p>
<Link to="/" className="mt-4 text-primary underline hover:no-underline">
{t('common.back')}
</Link>
</main>
);
}
+6 -6
View File
@@ -1,18 +1,18 @@
/*
* 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
*/
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Where the server's API is, when the console isn't served by the server itself. */
readonly VITE_API_BASE_URL: string;
/** Development only: a token that skips signing in. */
readonly VITE_ACCESS_TOKEN: string;
readonly VITE_OAUTH_SCOPES: string;
/** Development only: log JMAP traffic and form changes (lib/debug.ts). */
readonly VITE_DEBUG_JMAP?: string;
readonly VITE_DEBUG_FORMS?: string;
}
@@ -21,7 +21,7 @@ interface ImportMeta {
readonly env: ImportMetaEnv;
}
/** The console's version, set at build time. */
declare const __APP_VERSION__: string;
// inbuxa: the Sieve playground's page, relative to the console
// (sieve-playground.ts)
/** The Sieve playground's page, relative to the console (sieve-playground.ts). */
declare const __SIEVE_PLAYGROUND__: string;