4 Commits
Author SHA1 Message Date
Maurus Decimus b0b8e4e090 v1.0.10 2026-09-04 10:16:53 +02:00
Maurus Decimus af11f5119c v1.0.9 2026-08-24 15:44:51 +02:00
Maurus Decimus 8cab61a9c5 v1.0.8 2026-07-31 15:52:36 +02:00
Maurus Decimus 189e270785 v1.0.7 (fixes #17) 2026-07-30 17:16:05 +02:00
44 changed files with 1922 additions and 799 deletions
-1
View File
@@ -1,5 +1,4 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_OAUTH_CLIENT_ID=stalwart-webui
#VITE_ACCESS_TOKEN=OPEN_SESAME
VITE_OAUTH_SCOPES=
+9 -14
View File
@@ -12,19 +12,14 @@ dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.ignore
scripts/
*.md
!README.md
!CHANGELOG.md
/SPEC-*
.*
!.gitignore
!.prettierrc
!.env.development
!.github/
!.vscode/
.vscode/*
!.vscode/extensions.json
+53
View File
@@ -2,6 +2,59 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [1.0.10] - 2026-09-04
### Added
### Changed
### Fixed
- Re-added map entries and object list items are seeded with their schema defaults, including a value for every non-nullable boolean.
## [1.0.9] - 2026-08-24
### Added
- Server configurable OAuth client ID.
### Changed
### Fixed
## [1.0.8] - 2026-07-31
### Added
- Remember the last visited page when switching sections (credits @LinkPhoenix).
### Changed
- Enum list filters with many options render as a searchable combobox (credits @LinkPhoenix).
- Object cells display the variant label as a badge instead of the raw type name (credits @LinkPhoenix).
- Empty date pickers open on the current date and time (credits @LinkPhoenix).
### Fixed
- Custom logos no longer flash when navigating between pages.
- Landing no longer flashes "Select a view" before redirecting to the default page.
## [1.0.7] - 2026-07-30
### Added
- `Ctrl+K` / `Cmd+K` command palette for global search (credits @LinkPhoenix).
- Calendar date picker for date and time fields (credits @LinkPhoenix).
- Dynamic document titles per page (credits @LinkPhoenix).
### Changed
- Code-split the admin shell and heavy feature pages to speed up the initial load (credits @LinkPhoenix).
- Center forms horizontally on wide screens (credits @LinkPhoenix).
### Fixed
- Redirect URLs without a view to the first accessible page of their section (credits @LinkPhoenix).
- Sidebar groups auto-open and scroll the active item into view after navigation (credits @LinkPhoenix).
- Keep the sidebar section synced with the URL on full page loads (credits @LinkPhoenix).
- Date and time fields no longer shift values by the UTC offset when editing (credits @LinkPhoenix).
- Clip the table header background inside the rounded card border (credits @LinkPhoenix).
- Keep the selected account across page reloads (#17).
- Refresh open views when switching accounts (#17).
- Custom logos no longer flash the default logo while loading.
## [1.0.6] - 2026-07-28
### Added
+11 -2
View File
@@ -71,7 +71,6 @@ Configuration is done through Vite environment variables. Copy or edit `.env.dev
```
VITE_API_BASE_URL=http://localhost:443
VITE_OAUTH_CLIENT_ID=stalwart-webui
VITE_ACCESS_TOKEN=
VITE_OAUTH_SCOPES=
```
@@ -79,10 +78,20 @@ VITE_OAUTH_SCOPES=
| Variable | Description |
|---|---|
| `VITE_API_BASE_URL` | URL of the Stalwart server. Used for all API requests during development. In production builds (when empty or unset) requests are relative to the current origin. |
| `VITE_OAUTH_CLIENT_ID` | OAuth 2.0 client ID. Defaults to `stalwart-webui`. |
| `VITE_ACCESS_TOKEN` | When set, skips the OAuth flow entirely and uses this token for all requests. Useful for local development and testing. |
| `VITE_OAUTH_SCOPES` | Optional OAuth scopes. Omitted from the authorization request when empty. |
### OAuth client ID
The OAuth 2.0 client ID is not a build-time setting. It is read at runtime from a meta tag in `index.html`:
```html
<meta name="oauth-client-id" content="" />
```
The server rewrites the `content` attribute when it serves the page, so a single build works for any deployment. When no
client ID is configured the attribute is left empty and the panel falls back to `stalwart-webui`.
### Bypassing OAuth for development
Set `VITE_ACCESS_TOKEN` to a valid bearer token to skip the login page and go straight to the admin panel. You can obtain a token from the Stalwart server's token endpoint or use an API key:
+1
View File
@@ -4,6 +4,7 @@
<head>
<meta charset="UTF-8" />
<base href="/" />
<meta name="oauth-client-id" content="" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Portal</title>
+673 -298
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -1,7 +1,7 @@
{
"name": "stalwart-webui",
"private": true,
"version": "1.0.6",
"version": "1.0.10",
"description": "Stalwart WebUI",
"type": "module",
"scripts": {
@@ -16,6 +16,7 @@
"format:check": "prettier --check src/"
},
"dependencies": {
"@daypicker/react": "^10.0.1",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-collapsible": "^1.1.20",
@@ -35,14 +36,14 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"i18next": "^26.3.6",
"lucide-react": "^1.27.0",
"lucide-react": "^1.28.0",
"otpauth": "^9.5.1",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-i18next": "^17.0.11",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.1",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
@@ -53,7 +54,7 @@
"@types/node": "^26.1.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
@@ -64,7 +65,7 @@
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.65.0",
"vite": "^8.1.5",
"vite": "^8.2.0",
"vitest": "^4.1.10"
}
}
+5 -1
View File
@@ -4,14 +4,18 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Suspense } from 'react';
import { Outlet } from 'react-router-dom';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { Toaster } from '@/components/ui/toaster';
export default function App() {
return (
<ErrorBoundary>
<Outlet />
<Suspense fallback={<LoadingFallback fullScreen />}>
<Outlet />
</Suspense>
<Toaster />
</ErrorBoundary>
);
+87
View File
@@ -0,0 +1,87 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
import type { SearchIndexEntry } from '@/stores/schemaStore';
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const { t } = useTranslation();
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
const { query, setQuery, debouncedQuery, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
const groupLabels: Record<SearchIndexEntry['type'], string> = useMemo(
() => ({
link: t('globalSearch.pages', 'Pages'),
form: t('globalSearch.formSections', 'Form Sections'),
field: t('globalSearch.fields', 'Fields'),
}),
[t],
);
useEffect(() => {
if (!open) reset();
}, [open, reset]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0" showCloseButton={false}>
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
<Command
shouldFilter={false}
loop
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
>
<CommandInput
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
value={query}
onValueChange={setQuery}
trailing={
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
ESC
</kbd>
}
/>
<CommandList>
<CommandEmpty>
{debouncedQuery.trim()
? t('globalSearch.noResults', 'No results found.')
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
</CommandEmpty>
{Array.from(groups.entries()).map(([type, entries]) => (
<CommandGroup key={type} heading={groupLabels[type]}>
{entries.map((entry, idx) => {
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
const itemValue = `${type}-${idx}-${entry.viewName}`;
return (
<CommandItem key={itemValue} value={itemValue} onSelect={() => selectEntry(entry)}>
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
</div>
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
-245
View File
@@ -1,245 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, List, Settings, Plus } from 'lucide-react';
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { resolveObject } from '@/lib/schemaResolver';
import type { Schema } from '@/types/schema';
const MAX_RESULTS = 15;
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
link: 0,
form: 1,
field: 2,
};
function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null {
const resolved = resolveObject(schema, viewName);
if (!resolved) return null;
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
}
function getActionInfo(
entryType: SearchIndexEntry['type'],
objectKind: 'singleton' | 'object' | null,
t: (key: string, fallback: string) => string,
): { label: string; Icon: typeof List } {
if (entryType === 'link') {
return objectKind === 'singleton'
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
: { label: t('globalSearch.list', 'List'), Icon: List };
}
return objectKind === 'singleton'
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
}
function getNavigationPath(
entryType: SearchIndexEntry['type'],
objectKind: 'singleton' | 'object' | null,
section: string,
viewName: string,
): string {
const encodedView = viewName;
if (entryType === 'link') {
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`;
}
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`;
}
function friendlyName(viewName: string): string {
const stripped = viewName.replace(/^x:/, '');
const parts = stripped.split('/');
return parts[parts.length - 1];
}
interface GlobalSearchProps {
onAfterSelect?: () => void;
autoFocus?: boolean;
}
export function GlobalSearch({ onAfterSelect, autoFocus }: GlobalSearchProps = {}) {
const { t } = useTranslation();
const navigate = useNavigate();
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
link: t('globalSearch.pages', 'Pages'),
form: t('globalSearch.formSections', 'Form Sections'),
field: t('globalSearch.fields', 'Fields'),
};
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [dropdownOpen, setDropdownOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const schema = useSchemaStore((s) => s.schema);
const searchIndex = useSchemaStore((s) => s.searchIndex);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
setActiveIndex(-1);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setDebouncedQuery(value), 300);
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const results = useMemo(() => {
if (!debouncedQuery.trim() || !schema) return [];
const tokens = debouncedQuery
.toLowerCase()
.split(/\s+/)
.filter((s) => s.length > 0);
if (tokens.length === 0) return [];
const filtered = searchIndex.filter((entry) => {
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
for (const token of tokens) {
if (!haystack.includes(token)) return false;
}
const resolved = resolveObject(schema, entry.viewName);
if (!resolved) return false;
return hasObjectPermission(resolved.permissionPrefix, 'Get');
});
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
return filtered.slice(0, MAX_RESULTS);
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
const groups = useMemo(() => {
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
for (const entry of results) {
const arr = map.get(entry.type);
if (arr) arr.push(entry);
else map.set(entry.type, [entry]);
}
return map;
}, [results]);
const handleSelect = useCallback(
(entry: SearchIndexEntry) => {
if (!schema) return;
const objectKind = getObjectKind(schema, entry.viewName);
const path = getNavigationPath(entry.type, objectKind, entry.section, entry.viewName);
setDropdownOpen(false);
setQuery('');
setDebouncedQuery('');
navigate(path);
onAfterSelect?.();
},
[schema, navigate, onAfterSelect],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!dropdownOpen || results.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => (i + 1) % results.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => (i - 1 + results.length) % results.length);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
handleSelect(results[activeIndex]);
} else if (e.key === 'Escape') {
setDropdownOpen(false);
}
},
[dropdownOpen, results, activeIndex, handleSelect],
);
const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0;
return (
<div className="relative w-full max-w-md" ref={containerRef}>
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
autoFocus={autoFocus}
onChange={(e) => {
handleQueryChange(e.target.value);
setDropdownOpen(true);
}}
onFocus={() => {
if (query.trim()) setDropdownOpen(true);
}}
onKeyDown={handleKeyDown}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{showDropdown && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
{results.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
{t('globalSearch.noResults', 'No results found.')}
</div>
) : (
<div className="max-h-80 overflow-y-auto py-1">
{Array.from(groups.entries()).map(([type, entries]) => (
<div key={type}>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div>
{entries.map((entry) => {
const flatIdx = results.indexOf(entry);
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
return (
<button
key={`${type}-${entry.viewName}-${flatIdx}`}
type="button"
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${
flatIdx === activeIndex ? 'bg-accent' : ''
}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelect(entry);
}}
onMouseEnter={() => setActiveIndex(flatIdx)}
>
<ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden">
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span>
</button>
);
})}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
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')}>
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">{t('common.loading')}</p>
</div>
);
}
+9 -41
View File
@@ -4,9 +4,9 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect } from 'react';
import { useEffect, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { getApiBaseUrl } from '@/services/api';
import { getLogoState, loadLogoOnce, subscribeToLogo } from '@/lib/logoCache';
export function DefaultLogo() {
const { t } = useTranslation();
@@ -31,50 +31,18 @@ export function DefaultLogo() {
export default function Logo() {
const { t } = useTranslation();
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const logo = useSyncExternalStore(subscribeToLogo, getLogoState, getLogoState);
useEffect(() => {
const controller = new AbortController();
async function fetchLogo() {
try {
const response = await fetch(`${getApiBaseUrl()}/logo`, {
signal: controller.signal,
});
const contentType = response.headers.get('content-type') ?? '';
if (response.ok && contentType.startsWith('image/')) {
const blob = await response.blob();
if (!controller.signal.aborted) {
const url = URL.createObjectURL(blob);
setLogoUrl(url);
}
} else {
if (!controller.signal.aborted) setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
}
}
fetchLogo();
return () => {
controller.abort();
};
loadLogoOnce();
}, []);
useEffect(() => {
return () => {
if (logoUrl) {
URL.revokeObjectURL(logoUrl);
}
};
}, [logoUrl]);
if (logo.status === 'custom') {
return <img src={logo.url} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
}
if (logoUrl && !failed) {
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
if (logo.status === 'loading') {
return <span className="block h-7 w-[140px]" aria-hidden="true" />;
}
return <DefaultLogo />;
+1 -1
View File
@@ -742,7 +742,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
const sectionsToRender = buildSections(combinedForm, currentFields, isCreate, edition);
return (
<div className="space-y-6 max-w-4xl">
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-4">
<Button type="button" variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ArrowLeft className="h-5 w-5" />
+64 -61
View File
@@ -4,11 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect, type KeyboardEvent } from 'react';
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
import ReactMarkdown from 'react-markdown';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
@@ -20,14 +19,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Combobox, type ComboboxOption } from '@/components/ui/combobox';
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } from 'lucide-react';
import { Calendar } from '@/components/ui/calendar';
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ExpressionEditor } from '@/components/expression/ExpressionEditor';
import { OtpAuthField } from '@/components/forms/OtpAuthField';
import {
bytesToHuman,
humanToBytes,
@@ -38,7 +34,14 @@ import {
SIZE_UNITS,
DURATION_UNITS,
} from '@/lib/durationFormat';
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } from '@/lib/schemaResolver';
import {
resolveSchema,
resolveVariantForm,
resolveObject,
buildEmbeddedDefaults,
buildNewObjectValue,
} from '@/lib/schemaResolver';
import { cn } from '@/lib/utils';
import { useAccountStore } from '@/stores/accountStore';
import { useEffectiveEdition } from '@/components/forms/FormEditionContext';
import { useObjectList, useObjectLabel, useNoPermissionMessage, type ObjectOption } from '@/lib/objectOptions';
@@ -831,60 +834,69 @@ interface DateTimeFieldProps {
nullable?: boolean;
}
const DATE_TIME_FORMAT = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' });
function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldProps) {
const { t } = useTranslation();
const strValue = typeof value === 'string' ? value : '';
const toLocal = (iso: string): string => {
if (!iso) return '';
try {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
return d.toISOString().slice(0, 16);
} catch {
return '';
}
};
const selected = useMemo(() => {
const d = strValue ? new Date(strValue) : null;
return d && !isNaN(d.getTime()) ? d : null;
}, [strValue]);
const toIso = (local: string): string | null => {
if (!local) return nullable ? null : '';
return new Date(local).toISOString();
};
const localTime = (selected ?? new Date()).toTimeString().slice(0, 5);
const [local, setLocal] = useBufferedValue(strValue, toLocal);
const commit = () => {
const iso = toIso(local);
if (iso !== strValue) onChange(iso);
const commit = (day: Date, time: string) => {
const [hours, minutes] = time.split(':').map(Number);
const next = new Date(day);
next.setHours(hours || 0, minutes || 0, 0, 0);
onChange(next.toISOString());
};
if (readOnly) {
if (!strValue) {
return <span className="text-sm text-muted-foreground italic">{t('field.notSet', 'Not set')}</span>;
}
let formatted = strValue;
try {
const d = new Date(strValue);
if (!isNaN(d.getTime())) {
formatted = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(d);
}
// eslint-disable-next-line no-empty
} catch {}
return <span className="text-sm">{formatted}</span>;
return <span className="text-sm">{selected ? DATE_TIME_FORMAT.format(selected) : strValue}</span>;
}
return (
<div className="flex items-center gap-2">
<Input
type="datetime-local"
value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={commit}
className="flex-1"
/>
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className={cn('flex-1 justify-start text-left font-normal', !selected && 'text-muted-foreground')}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selected ? DATE_TIME_FORMAT.format(selected) : t('field.pickDate', 'Pick a date')}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selected ?? undefined}
defaultMonth={selected ?? undefined}
autoFocus
onSelect={(day) => {
if (day) commit(day, localTime);
}}
/>
<div className="border-t p-3">
<Input
type="time"
value={localTime}
disabled={!selected}
onChange={(e) => {
if (selected && e.target.value) commit(selected, e.target.value);
}}
aria-label={t('field.time', 'Time')}
/>
</div>
</PopoverContent>
</Popover>
{nullable && strValue && (
<Button
type="button"
@@ -1528,16 +1540,7 @@ function ObjectListField({
const addItem = () => {
const nextIndex = entries.length > 0 ? Math.max(...entries.map(([k]) => parseInt(k))) + 1 : 0;
let defaults: Record<string, unknown> = {};
if (resolvedSchema.type === 'single' && resolvedSchema.fields.defaults) {
defaults = { ...resolvedSchema.fields.defaults };
} else if (resolvedSchema.type === 'multiple' && resolvedSchema.variants[0]) {
defaults = { '@type': resolvedSchema.variants[0].name };
if (resolvedSchema.variants[0].fields?.defaults) {
defaults = { ...defaults, ...resolvedSchema.variants[0].fields.defaults };
}
}
onChange({ ...mapValue, [String(nextIndex)]: defaults });
onChange({ ...mapValue, [String(nextIndex)]: buildNewObjectValue(schema, objectName) });
};
const removeItem = (key: string) => {
@@ -1779,10 +1782,10 @@ function EnumMultiSelect({ enumName, items, onChange, readOnly, schema, minItems
if (variants.length > 10) {
const filtered = searchQuery
? variants.filter(
(v) =>
v.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
(v) =>
v.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
v.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: variants;
return (
@@ -2010,7 +2013,7 @@ function MapField({ keyClass, valueClass, value, onChange, readOnly, schema, min
if (valueClass.type === 'number') {
defaultValue = 0;
} else if (valueClass.type === 'object') {
defaultValue = {};
defaultValue = buildNewObjectValue(schema, valueClass.objectName);
}
onChange({ ...mapValue, [key]: defaultValue });
+34 -10
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect } from 'react';
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';
@@ -12,11 +12,33 @@ import { resolveObject } from '@/lib/schemaResolver';
import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { DashboardView } from '@/features/dashboard/components/DashboardView';
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
import { ActionPage } from '@/features/actions/ActionPage';
import { LoadingFallback } from '@/components/common/LoadingFallback';
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) })));
}
const DashboardView = lazyFeature(
() => 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(
() => import('@/features/tracing/components/TraceDetailView'),
(m) => m.TraceDetailView,
);
const ActionPage = lazyFeature(
() => import('@/features/actions/ActionPage'),
(m) => m.ActionPage,
);
interface MainContentProps {
viewName?: string;
@@ -32,10 +54,12 @@ 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 (
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
);
return <LoadingFallback />;
}
if (viewName.startsWith('Dashboard/')) {
@@ -84,7 +108,7 @@ export function MainContent({ viewName, id, section }: MainContentProps) {
}
if (id) {
if (resolved.objectName === 'x:Trace' && id !== 'new') {
if (resolved.objectName === 'x:Trace') {
return <TraceDetailView viewName={viewName} objectId={id} />;
}
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
+70 -32
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons;
@@ -16,13 +16,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { useUIStore } from '@/stores/uiStore';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
import {
visibleLayouts,
findFirstVisibleLinkInLayout,
findFirstAccessibleLinkInLayout,
isLinkEnterprise,
isLinkVisible,
} from '@/lib/layout';
import { visibleLayouts, isLinkEnterprise, isLinkVisible } from '@/lib/layout';
import { sectionLandingLink } from '@/lib/lastVisited';
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
function LucideIcon({ name, className }: { name: string; className?: string }) {
@@ -74,6 +69,25 @@ function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean
return false;
}
interface AutoOpenCollapsibleProps {
containsActive: boolean;
children: React.ReactNode;
}
function AutoOpenCollapsible({ containsActive, children }: AutoOpenCollapsibleProps) {
const [open, setOpen] = useState(containsActive);
const [prevContainsActive, setPrevContainsActive] = useState(containsActive);
if (containsActive !== prevContainsActive) {
setPrevContainsActive(containsActive);
if (containsActive) setOpen(true);
}
return (
<Collapsible open={open} onOpenChange={setOpen}>
{children}
</Collapsible>
);
}
function checkLinkVisible(viewName: string): boolean {
const schema = useSchemaStore.getState().schema;
if (!schema) return true;
@@ -95,6 +109,8 @@ function checkIsEnterprise(viewName: string): boolean {
return isLinkEnterprise(schema, viewName, edition);
}
type ActiveItemRef = (el: HTMLButtonElement | null) => void;
interface SidebarSubItemProps {
item: LayoutSubItem;
depth: number;
@@ -103,9 +119,19 @@ interface SidebarSubItemProps {
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
activeItemRef: ActiveItemRef;
}
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
function SidebarSubItem({
item,
depth,
sectionName,
currentPath,
navigate,
edition,
onUpsell,
activeItemRef,
}: SidebarSubItemProps) {
if (item.type === 'link') {
if (!checkLinkVisible(item.viewName)) return null;
@@ -120,6 +146,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
return (
<Button
variant="ghost"
ref={isActive ? activeItemRef : undefined}
className={cn(
'w-full justify-start gap-2 font-normal',
isActive && 'bg-accent text-accent-foreground',
@@ -145,7 +172,7 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
@@ -167,10 +194,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
activeItemRef={activeItemRef}
/>
))}
</CollapsibleContent>
</Collapsible>
</AutoOpenCollapsible>
);
}
@@ -184,9 +212,18 @@ interface SidebarTopItemProps {
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
activeItemRef: ActiveItemRef;
}
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
function SidebarTopItem({
item,
sectionName,
currentPath,
navigate,
edition,
onUpsell,
activeItemRef,
}: SidebarTopItemProps) {
if ('link' in item) {
const { name, icon, viewName } = item.link;
@@ -203,6 +240,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
return (
<Button
variant="ghost"
ref={isActive ? activeItemRef : undefined}
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
onClick={() => {
if (isLocked) {
@@ -226,7 +264,7 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<AutoOpenCollapsible containsActive={containsActive}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
@@ -245,10 +283,11 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
activeItemRef={activeItemRef}
/>
))}
</CollapsibleContent>
</Collapsible>
</AutoOpenCollapsible>
);
}
@@ -264,23 +303,19 @@ export function Sidebar() {
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
const schema = useSchemaStore((s) => s.schema);
const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
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;
}, []);
const layouts = useMemo(
() =>
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
[schema, edition, hasObjectPermission, hasPermission],
);
useEffect(() => {
if (!schema) return;
if (layouts.length === 0) return;
if (!layouts.find((l) => l.name === activeSection)) {
setActiveSection(layouts[0].name);
}
}, [schema, layouts, activeSection, setActiveSection]);
const layouts = useMemo(() => {
if (!schema) return [];
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
return visibleLayouts(schema, edition, canGet, hasPermission);
}, [schema, edition, permissions, hasPermission]);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -289,6 +324,10 @@ export function Sidebar() {
}
}, [location.pathname, setSidebarOpen]);
useEffect(() => {
activeItem.current?.scrollIntoView({ block: 'nearest' });
}, [location.pathname, activeSection]);
if (!sidebarOpen || !schema) return null;
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
@@ -296,10 +335,8 @@ export function Sidebar() {
const handleSectionClick = (target: Layout) => {
setActiveSection(target.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const first =
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
const first = sectionLandingLink(schema, target, edition, canGet, hasPermission);
if (first) navigate(`/${target.name}/${first}`);
};
@@ -322,6 +359,7 @@ export function Sidebar() {
navigate={navigate}
edition={edition}
onUpsell={() => setUpsellOpen(true)}
activeItemRef={activeItemRef}
/>
))}
</nav>
+32 -16
View File
@@ -9,8 +9,7 @@ import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
import { Button } from '@/components/ui/button';
import { GlobalSearch } from '@/components/common/GlobalSearch';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { CommandPalette } from '@/components/common/CommandPalette';
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,14 +22,17 @@ import {
import Logo from '@/components/common/Logo';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
import { visibleLayouts } from '@/lib/layout';
import { sectionLandingLink } from '@/lib/lastVisited';
import { useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore';
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
const IS_MAC = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
function getIcon(name: string): LucideIcons.LucideIcon {
const formatted = name
.split('-')
@@ -55,7 +57,18 @@ export function TopBar() {
const hasPermission = useAccountStore((s) => s.hasPermission);
const schema = useSchemaStore((s) => s.schema);
const [upsellOpen, setUpsellOpen] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
setPaletteOpen((open) => !open);
}
}
document.addEventListener('keydown', handleGlobalKeyDown);
return () => document.removeEventListener('keydown', handleGlobalKeyDown);
}, []);
const navigableLayouts = schema
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
@@ -81,7 +94,17 @@ export function TopBar() {
</TooltipProvider>
<div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
<GlobalSearch />
<button
type="button"
onClick={() => setPaletteOpen(true)}
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
>
<Search className="h-4 w-4" />
<span className="flex-1 text-left">{t('globalSearch.placeholder', 'Search pages, fields, settings...')}</span>
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
{IS_MAC ? '⌘K' : 'Ctrl K'}
</kbd>
</button>
</div>
<div className="ml-auto flex items-center gap-2 md:ml-0">
@@ -91,18 +114,13 @@ export function TopBar() {
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileSearchOpen(true)}
onClick={() => setPaletteOpen(true)}
aria-label={t('search', 'Search')}
>
<Search className="h-4 w-4" />
</Button>
<Dialog open={mobileSearchOpen} onOpenChange={setMobileSearchOpen}>
<DialogContent className="top-4 translate-y-0 max-w-[calc(100vw-2rem)] p-4">
<DialogTitle className="sr-only">{t('search', 'Search')}</DialogTitle>
<GlobalSearch autoFocus onAfterSelect={() => setMobileSearchOpen(false)} />
</DialogContent>
</Dialog>
<CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />
<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" />}
@@ -127,9 +145,7 @@ export function TopBar() {
onClick={() => {
setActiveSection(layout.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const firstLink =
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
const firstLink = sectionLandingLink(schema, layout, edition, canGet, hasPermission);
if (firstLink) {
navigate(`/${layout.name}/${firstLink}`);
}
+23 -4
View File
@@ -25,6 +25,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import { Combobox } from '@/components/ui/combobox';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { formatSize as fmtSize, formatDuration as fmtDuration } from '@/lib/durationFormat';
@@ -65,6 +66,8 @@ import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
import type { ResolvedSchema } from '@/lib/schemaResolver';
const ENUM_FILTER_COMBOBOX_THRESHOLD = 15;
const PAGE_SIZE = 25;
const MAX_REPORTED_ERRORS = 3;
@@ -285,9 +288,11 @@ function renderCellValue(
case 'object': {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
if ('@type' in obj && typeof obj['@type'] === 'string') {
return obj['@type'];
const typeName = (value as Record<string, unknown>)['@type'];
if (typeof typeName === 'string') {
const objSchema = schema.schemas[ft.objectName];
const variant = objSchema?.type === 'multiple' ? objSchema.variants.find((v) => v.name === typeName) : null;
return <Badge variant="secondary">{variant?.label ?? typeName}</Badge>;
}
}
return <span className="text-muted-foreground">-</span>;
@@ -869,6 +874,20 @@ export function DynamicList({ viewName }: DynamicListProps) {
case 'enum': {
const enumVariants = schema!.enums[filterDef.enumName] ?? [];
if (enumVariants.length > ENUM_FILTER_COMBOBOX_THRESHOLD) {
return wrapper(
<Combobox
options={[
{ value: '__all__', label: t('filters.all', 'All') },
...enumVariants.map((v) => ({ value: v.name, label: v.label })),
]}
value={value || '__all__'}
onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}
searchPlaceholder={t('common.searchPlaceholder', 'Search...')}
emptyText={t('field.noMatches', 'No matches')}
/>,
);
}
return wrapper(
<Select value={value || '__all__'} onValueChange={(v) => handleFilterSelectChange(filterDef.field, v)}>
<SelectTrigger>
@@ -1215,7 +1234,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
)}
<div className="rounded-lg border bg-background shadow-sm">
<div className="overflow-x-auto">
<div className="overflow-x-auto rounded-[calc(var(--radius-lg)-1px)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted">
+156
View File
@@ -0,0 +1,156 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker, getDefaultClassNames, type DayButton } from '@daypicker/react';
import { cn } from '@/lib/utils';
import { Button, buttonVariants } from '@/components/ui/button';
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant'];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=popover-content]_&]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn('relative flex flex-col gap-4 md:flex-row', defaultClassNames.months),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', defaultClassNames.nav),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next,
),
month_caption: cn(
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption,
),
dropdowns: cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns,
),
dropdown_root: cn(
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
defaultClassNames.dropdown_root,
),
dropdown: cn('absolute inset-0 bg-popover opacity-0', defaultClassNames.dropdown),
caption_label: cn(
'font-medium select-none',
captionLayout === 'label'
? 'text-sm'
: 'flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label,
),
month_grid: cn('w-full border-collapse', defaultClassNames.month_grid),
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday,
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn('w-(--cell-size) select-none', defaultClassNames.week_number_header),
week_number: cn('text-[0.8rem] text-muted-foreground select-none', defaultClassNames.week_number),
day: cn(
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-md',
props.showWeekNumber
? '[&:nth-child(2)[data-selected=true]_button]:rounded-l-md'
: '[&:first-child[data-selected=true]_button]:rounded-l-md',
defaultClassNames.day,
),
range_start: cn('rounded-l-md bg-accent', defaultClassNames.range_start),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
defaultClassNames.today,
),
outside: cn('text-muted-foreground aria-selected:text-muted-foreground', defaultClassNames.outside),
disabled: cn('text-muted-foreground opacity-50', defaultClassNames.disabled),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => (
<div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
),
Chevron: ({ className, orientation, ...props }) => {
const Icon = orientation === 'left' ? ChevronLeft : orientation === 'right' ? ChevronRight : ChevronDown;
return <Icon className={cn('size-4', className)} {...props} />;
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
</td>
),
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className,
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };
+3 -2
View File
@@ -41,8 +41,8 @@ const CommandDialog = ({ children, ...props }: DialogProps) => {
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & { trailing?: React.ReactNode }
>(({ className, trailing, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
@@ -53,6 +53,7 @@ const CommandInput = React.forwardRef<
)}
{...props}
/>
{trailing}
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
+8 -6
View File
@@ -35,8 +35,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { showCloseButton?: boolean }
>(({ className, children, showCloseButton = true, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
@@ -48,10 +48,12 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
));
+18
View File
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect } from 'react';
const APP_NAME = 'Stalwart WebUI';
export function useDocumentTitle(title?: string | null) {
useEffect(() => {
document.title = title ? `${title} · ${APP_NAME}` : APP_NAME;
return () => {
document.title = APP_NAME;
};
}, [title]);
}
+133
View File
@@ -0,0 +1,133 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { List, Plus, Settings } from 'lucide-react';
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { resolveObject } from '@/lib/schemaResolver';
import type { Schema } from '@/types/schema';
const MAX_RESULTS = 15;
const DEBOUNCE_MS = 300;
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
link: 0,
form: 1,
field: 2,
};
export type ObjectKind = 'singleton' | 'object' | null;
export function getObjectKind(schema: Schema, viewName: string): ObjectKind {
const resolved = resolveObject(schema, viewName);
if (!resolved) return null;
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
}
export function getActionInfo(
entryType: SearchIndexEntry['type'],
objectKind: ObjectKind,
t: (key: string, fallback: string) => string,
): { label: string; Icon: typeof List } {
if (objectKind === 'singleton') {
return { label: t('globalSearch.settings', 'Settings'), Icon: Settings };
}
return entryType === 'link'
? { label: t('globalSearch.list', 'List'), Icon: List }
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
}
function getNavigationPath(
entryType: SearchIndexEntry['type'],
objectKind: ObjectKind,
section: string,
viewName: string,
): string {
if (objectKind === 'singleton') return `/${section}/${viewName}/singleton`;
return entryType === 'link' ? `/${section}/${viewName}` : `/${section}/${viewName}/new`;
}
export function friendlyName(viewName: string): string {
const stripped = viewName.replace(/^x:/, '');
const parts = stripped.split('/');
return parts[parts.length - 1];
}
export function useGlobalSearch(onAfterSelect?: () => void) {
const navigate = useNavigate();
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const schema = useSchemaStore((s) => s.schema);
const searchIndex = useSchemaStore((s) => s.searchIndex);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const handleQueryChange = useCallback((value: string) => {
setQuery(value);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setDebouncedQuery(value), DEBOUNCE_MS);
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const results = useMemo(() => {
if (!debouncedQuery.trim() || !schema) return [];
const tokens = debouncedQuery
.toLowerCase()
.split(/\s+/)
.filter((s) => s.length > 0);
if (tokens.length === 0) return [];
const filtered = searchIndex.filter((entry) => {
const haystack = (entry.text + ' ' + (entry.keywords?.join(' ') ?? '')).toLowerCase();
for (const token of tokens) {
if (!haystack.includes(token)) return false;
}
const resolved = resolveObject(schema, entry.viewName);
if (!resolved) return false;
return hasObjectPermission(resolved.permissionPrefix, 'Get');
});
filtered.sort((a, b) => TYPE_ORDER[a.type] - TYPE_ORDER[b.type]);
return filtered.slice(0, MAX_RESULTS);
}, [debouncedQuery, searchIndex, schema, hasObjectPermission]);
const groups = useMemo(() => {
const map = new Map<SearchIndexEntry['type'], SearchIndexEntry[]>();
for (const entry of results) {
const arr = map.get(entry.type);
if (arr) arr.push(entry);
else map.set(entry.type, [entry]);
}
return map;
}, [results]);
const selectEntry = useCallback(
(entry: SearchIndexEntry) => {
if (!schema) return;
const objectKind = getObjectKind(schema, entry.viewName);
navigate(getNavigationPath(entry.type, objectKind, entry.section, entry.viewName));
onAfterSelect?.();
},
[schema, navigate, onAfterSelect],
);
const reset = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setQuery('');
setDebouncedQuery('');
}, []);
return { query, setQuery: handleQueryChange, debouncedQuery, results, groups, selectEntry, reset, schema };
}
+9 -1
View File
@@ -60,6 +60,7 @@
"preset30d": "Last 30 days",
"preset7d": "Last 7 days",
"preset90d": "Last 90 days",
"title": "Dashboard",
"to": "To"
},
"deliveryTrace": {
@@ -169,10 +170,12 @@
"none": "None",
"notSet": "Not set",
"optional": "(optional)",
"pickDate": "Pick a date",
"required": "required",
"selectEllipsis": "Select...",
"selectKey": "Select key...",
"selectOptions": "Select options...",
"time": "Time",
"type": "Type",
"typeAndPressEnter": "Type and press Enter...",
"unknownObjectType": "Unknown object type: {{name}}"
@@ -222,7 +225,10 @@
"list": "List",
"noResults": "No results found.",
"pages": "Pages",
"settings": "Settings"
"placeholder": "Search pages, fields, settings...",
"settings": "Settings",
"title": "Search",
"typeToSearch": "Type to search the admin panel."
},
"jmapErrors": {
"addressBookHasContents": "This address book has contacts. Remove them first.",
@@ -287,6 +293,7 @@
"continue": "Continue",
"error": "An unexpected error occurred",
"prompt": "Enter your account name to continue",
"title": "Sign in",
"usernamePlaceholder": "[email protected]"
},
"logo": {
@@ -305,6 +312,7 @@
"missingParams": "Missing authorization code or state parameter",
"processing": "Completing sign in...",
"stateMismatch": "State parameter mismatch. Please try logging in again.",
"title": "Signing in",
"tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}"
},
"otp": {
+53
View File
@@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import {
findFirstAccessibleLinkInLayout,
findFirstVisibleLinkInLayout,
isLinkAccessible,
type CanGet,
type HasPermission,
} from '@/lib/layout';
import type { Layout, Schema } from '@/types/schema';
const STORAGE_KEY = 'stalwart-last-visited';
function readAll(): Record<string, unknown> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
export function rememberLastVisited(section: string, viewName: string): void {
try {
const all = readAll();
if (all[section] === viewName) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...all, [section]: viewName }));
} catch {
return;
}
}
export function sectionLandingLink(
schema: Schema,
layout: Layout,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
const last = readAll()[layout.name];
if (typeof last === 'string' && isLinkAccessible(schema, last, edition, canGet, hasPerm)) {
return last;
}
return (
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPerm) ??
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPerm)
);
}
+40
View File
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { getApiBaseUrl } from '@/services/api';
export type LogoState = { status: 'loading' } | { status: 'custom'; url: string } | { status: 'default' };
let state: LogoState = { status: 'loading' };
let started = false;
const listeners = new Set<() => void>();
export function getLogoState(): LogoState {
return state;
}
export function subscribeToLogo(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function loadLogoOnce(): void {
if (started) return;
started = true;
fetch(`${getApiBaseUrl()}/logo`)
.then(async (response) => {
const contentType = response.headers.get('content-type') ?? '';
if (!response.ok || !contentType.startsWith('image/')) return null;
return URL.createObjectURL(await response.blob());
})
.catch(() => null)
.then((url) => {
state = url ? { status: 'custom', url } : { status: 'default' };
listeners.forEach((notify) => notify());
});
}
+56
View File
@@ -0,0 +1,56 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect, afterEach, vi } from 'vitest';
const originalHead = document.head.innerHTML;
async function loadWithMeta(meta: string) {
document.head.innerHTML = meta;
vi.resetModules();
const { getOAuthClientId } = await import('./oauthClientId');
return getOAuthClientId;
}
afterEach(() => {
document.head.innerHTML = originalHead;
vi.resetModules();
});
describe('getOAuthClientId', () => {
it('prefers the client id injected by the server', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="pocket-id-client" />');
expect(getOAuthClientId()).toBe('pocket-id-client');
});
it('trims surrounding whitespace from the injected client id', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" pocket-id-client " />');
expect(getOAuthClientId()).toBe('pocket-id-client');
});
it('falls back to the built-in default when the placeholder is empty', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="" />');
expect(getOAuthClientId()).toBe('stalwart-webui');
});
it('falls back to the built-in default when the placeholder is only whitespace', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content=" " />');
expect(getOAuthClientId()).toBe('stalwart-webui');
});
it('falls back to the built-in default when the placeholder is absent', async () => {
const getOAuthClientId = await loadWithMeta('');
expect(getOAuthClientId()).toBe('stalwart-webui');
});
it('reads the document only once', async () => {
const getOAuthClientId = await loadWithMeta('<meta name="oauth-client-id" content="pocket-id-client" />');
expect(getOAuthClientId()).toBe('pocket-id-client');
document.head.innerHTML = '<meta name="oauth-client-id" content="changed-later" />';
expect(getOAuthClientId()).toBe('pocket-id-client');
});
});
+17
View File
@@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
const DEFAULT_CLIENT_ID = 'stalwart-webui';
let cached: string | undefined;
export function getOAuthClientId(): string {
if (cached !== undefined) return cached;
const injected = document.querySelector('meta[name="oauth-client-id"]')?.getAttribute('content')?.trim();
cached = injected ? injected : DEFAULT_CLIENT_ID;
return cached;
}
+113
View File
@@ -15,6 +15,7 @@ import {
deepMerge,
buildCreateDefaults,
buildEmbeddedDefaults,
buildNewObjectValue,
} from './schemaResolver';
import { getDisplayProperty } from './schemaResolver';
@@ -865,3 +866,115 @@ describe('getDisplayProperty', () => {
expect(getDisplayProperty(schema, 'x:NoLabel')).toBe('title');
});
});
const structSchema: Schema = {
objects: {},
schemas: {
'x:Service': { type: 'single', schemaName: 'x:Service' },
'x:Listener': { type: 'single', schemaName: 'x:Listener' },
'x:Tls': { type: 'single', schemaName: 'x:Tls' },
'x:Store': {
type: 'multiple',
variants: [
{ name: 'S3', label: 'S3', schemaName: 'x:S3Store' },
{ name: 'Manual', label: 'Manual' },
],
},
},
fields: {
'x:Service': {
properties: {
hostname: {
description: '',
type: { type: 'string', format: 'string', nullable: true },
update: 'mutable',
},
cleartext: { description: '', type: { type: 'boolean' }, update: 'mutable' },
},
},
'x:Listener': {
properties: {
enabled: { description: '', type: { type: 'boolean' }, update: 'mutable' },
proxied: { description: '', type: { type: 'boolean' }, update: 'mutable' },
readOnly: { description: '', type: { type: 'boolean' }, update: 'serverSet' },
tls: { description: '', type: { type: 'object', objectName: 'x:Tls' }, update: 'mutable' },
fallback: {
description: '',
type: { type: 'object', objectName: 'x:Tls', nullable: true },
update: 'mutable',
},
},
defaults: {
enabled: true,
},
},
'x:Tls': {
properties: {
implicit: { description: '', type: { type: 'boolean' }, update: 'mutable' },
certificateId: {
description: '',
type: { type: 'string', format: 'string', nullable: true },
update: 'mutable',
},
},
},
'x:S3Store': {
properties: {
bucket: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
allowInvalidCerts: { description: '', type: { type: 'boolean' }, update: 'mutable' },
},
defaults: {
bucket: 'stalwart',
},
},
},
forms: {},
lists: {},
enums: {},
dashboards: [],
layouts: [],
};
describe('buildNewObjectValue', () => {
it('seeds non-nullable booleans a struct has no defaults for', () => {
expect(buildNewObjectValue(structSchema, 'x:Service')).toEqual({ cleartext: false });
});
it('keeps schema defaults and only fills the missing booleans', () => {
const result = buildNewObjectValue(structSchema, 'x:Listener');
expect(result.enabled).toBe(true);
expect(result.proxied).toBe(false);
});
it('skips serverSet properties', () => {
expect(buildNewObjectValue(structSchema, 'x:Listener')).not.toHaveProperty('readOnly');
});
it('recurses into non-nullable embedded objects and skips nullable ones', () => {
const result = buildNewObjectValue(structSchema, 'x:Listener');
expect(result.tls).toEqual({ implicit: false });
expect(result).not.toHaveProperty('fallback');
});
it('seeds the first variant with its @type, defaults and booleans', () => {
expect(buildNewObjectValue(structSchema, 'x:Store')).toEqual({
'@type': 'S3',
bucket: 'stalwart',
allowInvalidCerts: false,
});
});
it('honours an explicit variant name', () => {
expect(buildNewObjectValue(structSchema, 'x:Store', 'Manual')).toEqual({ '@type': 'Manual' });
});
it('returns an empty object for an unknown object name', () => {
expect(buildNewObjectValue(structSchema, 'x:Unknown')).toEqual({});
});
it('still merges parent defaults into embedded children', () => {
const result = buildNewObjectValue(embeddedSchema, 'x:Model', 'FtrlCcfh');
expect(result.featureL2Normalize).toBe(true);
expect((result.parameters as Record<string, unknown>).numFeatures).toBe('20');
});
});
+56
View File
@@ -260,6 +260,62 @@ export function buildEmbeddedDefaults(
return result;
}
export function buildNewObjectValue(schema: Schema, objectName: string, variantName?: string): Record<string, unknown> {
const result = buildEmbeddedDefaults(schema, objectName, {}, variantName);
return completeStructDefaults(schema, objectName, (result['@type'] as string | undefined) ?? variantName, result);
}
function completeStructDefaults(
schema: Schema,
objectName: string,
variantName: string | undefined,
target: Record<string, unknown>,
): Record<string, unknown> {
const resolved = resolveSchema(schema, objectName);
if (!resolved) return target;
const fields =
resolved.type === 'single'
? resolved.fields
: ((variantName ? resolved.variants.find((v) => v.name === variantName) : resolved.variants[0])?.fields ?? null);
if (!fields) return target;
for (const [propName, propDef] of Object.entries(fields.properties)) {
if (propDef.update === 'serverSet') continue;
const t = propDef.type;
if (t.type === 'boolean') {
if (!(propName in target)) {
target[propName] = false;
}
continue;
}
if (t.type !== 'object' || t.nullable) continue;
const current = target[propName];
if (current !== undefined && !isPlainRecord(current)) continue;
const overrides = isPlainRecord(current) ? current : {};
const nestedEntry = schema.schemas[t.objectName];
const nestedVariant =
nestedEntry?.type === 'multiple'
? ((overrides['@type'] as string | undefined) ?? nestedEntry.variants[0]?.name)
: undefined;
const nested = completeStructDefaults(schema, t.objectName, nestedVariant, { ...overrides });
if (Object.keys(nested).length > 0) {
target[propName] = nested;
}
}
return target;
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function getDisplayProperty(schema: Schema, objectName: string): string {
const list = schema.lists[objectName];
if (list?.labelProperty) return list.labelProperty;
+4 -1
View File
@@ -12,10 +12,11 @@ import './index.css';
import App from './App';
import LoginPage from './pages/LoginPage';
import OAuthCallback from './pages/OAuthCallback';
import AdminPanel from './pages/AdminPanel';
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';
(() => {
try {
@@ -34,6 +35,8 @@ import { getBasePath } from './lib/basePath';
document.documentElement.classList.toggle('dark', !!prefersDark);
})();
loadLogoOnce();
const basePath = getBasePath();
const router = createBrowserRouter(
+9
View File
@@ -0,0 +1,9 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { lazy } from 'react';
export const AdminPanel = lazy(() => import('./AdminPanel'));
+64 -42
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/stores/authStore';
@@ -17,7 +17,7 @@ import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar';
import { MainContent } from '@/components/layout/MainContent';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { BootstrapWizard } from '@/components/bootstrap/BootstrapWizard';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import {
findFirstAccessibleLinkInLayout,
findFirstVisibleLinkInLayout,
@@ -25,7 +25,13 @@ import {
visibleLayouts,
} from '@/lib/layout';
import { usePermissions } from '@/hooks/usePermissions';
import { Loader2 } from 'lucide-react';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { friendlyName } from '@/hooks/useGlobalSearch';
import { rememberLastVisited, sectionLandingLink } from '@/lib/lastVisited';
const BootstrapWizard = lazy(() =>
import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })),
);
export default function AdminPanel() {
const { t } = useTranslation();
@@ -69,9 +75,10 @@ export default function AdminPanel() {
const setSchema = useSchemaStore((s) => s.setSchema);
const setAccountInfo = useAccountStore((s) => s.setAccountInfo);
const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const permissions = useAccountStore((s) => s.permissions);
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 sidebarOpen = useUIStore((s) => s.sidebarOpen);
const { canViewObject } = usePermissions();
@@ -79,6 +86,26 @@ 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;
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, t]);
useDocumentTitle(pageTitle);
useEffect(() => {
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
if (bypassToken && !accessToken) {
@@ -140,20 +167,19 @@ export default function AdminPanel() {
};
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
const hasPermission = useAccountStore((s) => s.hasPermission);
const isBootstrapMode = useMemo(() => {
if (!schema) return false;
if (!canViewObject('x:Bootstrap')) return false;
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const hasPerm = (perm: string) => hasPermission(perm);
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, hasObjectPermission, hasPermission]);
}, [schema, canViewObject, edition, permissions]);
useEffect(() => {
if (!schema) return;
if (isBootstrapMode) return;
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const hasPerm = (perm: string) => hasPermission(perm);
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) {
@@ -169,50 +195,44 @@ export default function AdminPanel() {
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)) {
setActiveSection(fallback.layoutName);
if (fallback.link) {
navigate(`/${fallback.layoutName}/${fallback.link}`, { replace: true });
}
redirectTo(fallback.layoutName, fallback.link);
return;
}
}
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) {
setActiveSection(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) {
setActiveSection(target.layoutName);
if (target.link) {
navigate(`/${target.layoutName}/${target.link}`, { replace: true });
}
}
}, [
section,
viewName,
schema,
setActiveSection,
navigate,
edition,
hasObjectPermission,
hasPermission,
isBootstrapMode,
]);
if (target) redirectTo(target.layoutName, target.link);
}, [section, viewName, schema, setActiveSection, navigate, edition, permissions, isBootstrapMode]);
if (initializing) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">{t('common.loading')}</p>
</div>
</div>
);
return <LoadingFallback fullScreen />;
}
if (initError) {
@@ -237,7 +257,9 @@ export default function AdminPanel() {
if (isBootstrapMode) {
return (
<ErrorBoundary>
<BootstrapWizard />
<Suspense fallback={<LoadingFallback fullScreen />}>
<BootstrapWizard />
</Suspense>
</ErrorBoundary>
);
}
@@ -250,7 +272,7 @@ export default function AdminPanel() {
<main
className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`}
>
<ErrorBoundary>
<ErrorBoundary key={activeAccountId ?? 'none'}>
<MainContent viewName={viewName} id={id} section={section} />
</ErrorBoundary>
</main>
+3
View File
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { ArrowRight, Loader2 } from 'lucide-react';
import Logo from '@/components/common/Logo';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
@@ -23,6 +24,8 @@ export default function LoginPage() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useDocumentTitle(t('login.title', 'Sign in'));
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const trimmed = username.trim();
+3
View File
@@ -6,10 +6,13 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
export default function NotFound() {
const { t } = useTranslation();
useDocumentTitle(t('errors.notFound'));
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
+3
View File
@@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/authStore';
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
import { getBasePath } from '@/lib/basePath';
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
@@ -19,6 +20,8 @@ export default function OAuthCallback() {
const [searchParams] = useSearchParams();
const [error, setError] = useState<string | null>(null);
useDocumentTitle(t('oauth.title', 'Signing in'));
useEffect(() => {
async function handleCallback() {
try {
+2 -1
View File
@@ -6,6 +6,7 @@
import { useAuthStore } from '../stores/authStore';
import { getBasePath } from '@/lib/basePath';
import { getOAuthClientId } from '@/lib/oauthClientId';
export function getApiBaseUrl(): string {
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
@@ -45,7 +46,7 @@ export async function refreshAccessToken(): Promise<void> {
throw new Error('No refresh token or token endpoint available');
}
const clientId = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
const clientId = getOAuthClientId();
try {
const response = await fetch(tokenEndpoint, {
+4 -4
View File
@@ -6,9 +6,9 @@
import { getApiBaseUrl } from '@/services/api';
import { getBasePath } from '@/lib/basePath';
import { getOAuthClientId } from '@/lib/oauthClientId';
import i18n from '@/i18n';
const CLIENT_ID = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
const SCOPES = import.meta.env.VITE_OAUTH_SCOPES as string | undefined;
const SESSION_PREFIX = 'stalwart-oauth-';
@@ -97,7 +97,7 @@ export async function exchangeCode(
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
client_id: CLIENT_ID,
client_id: getOAuthClientId(),
redirect_uri: redirectUri,
});
@@ -153,7 +153,7 @@ export async function startAuthFlow(username: string, returnUrl?: string | null)
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
client_id: getOAuthClientId(),
redirect_uri: getRedirectUri(),
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
@@ -206,7 +206,7 @@ export function getPostLogoutRedirectUri(): string {
export function buildEndSessionUrl(endSessionEndpoint: string, postLogoutRedirectUri: string): string {
const params = new URLSearchParams({
client_id: CLIENT_ID,
client_id: getOAuthClientId(),
post_logout_redirect_uri: postLogoutRedirectUri,
});
const sep = endSessionEndpoint.includes('?') ? '&' : '?';
+54
View File
@@ -6,6 +6,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAuthStore } from './authStore';
import { useCacheStore } from './cacheStore';
const initialState = {
accessToken: null,
@@ -21,6 +22,7 @@ const initialState = {
describe('authStore', () => {
beforeEach(() => {
useAuthStore.setState(initialState);
useCacheStore.getState().clearAll();
vi.restoreAllMocks();
});
@@ -124,6 +126,29 @@ describe('authStore', () => {
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
});
it('preserves an activeAccountId that exists in the new accounts', () => {
useAuthStore.setState({ activeAccountId: 'acc-2' });
useAuthStore.getState().setSession(
{
'acc-1': { name: 'Personal', isPersonal: true },
'acc-2': { name: 'Group', isPersonal: false },
},
'acc-1',
'https://api',
);
expect(useAuthStore.getState().activeAccountId).toBe('acc-2');
});
it('falls back to primaryAccountId when the previous activeAccountId is unknown', () => {
useAuthStore.setState({ activeAccountId: 'gone' });
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
});
});
describe('switchAccount', () => {
@@ -149,6 +174,35 @@ describe('authStore', () => {
useAuthStore.getState().switchAccount('nonexistent');
expect(useAuthStore.getState().activeAccountId).toBe('a1');
});
it('clears cached objects when the account changes', () => {
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
useCacheStore.getState().setObjectList('Mailbox', [{ id: 'm1', label: 'Inbox' }]);
useAuthStore.setState({
accounts: {
a1: { name: 'A1', isPersonal: true },
a2: { name: 'A2', isPersonal: false },
},
activeAccountId: 'a1',
});
useAuthStore.getState().switchAccount('a2');
expect(useCacheStore.getState().displayNames).toEqual({});
expect(useCacheStore.getState().objectLists).toEqual({});
});
it('keeps the cache when switching to the already active account', () => {
useCacheStore.getState().setDisplayNames('Mailbox', { m1: 'Inbox' });
useAuthStore.setState({
accounts: { a1: { name: 'A1', isPersonal: true } },
activeAccountId: 'a1',
});
useAuthStore.getState().switchAccount('a1');
expect(useCacheStore.getState().getDisplayName('Mailbox', 'm1')).toBe('Inbox');
});
});
describe('logout', () => {
+7 -3
View File
@@ -6,6 +6,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useCacheStore } from '@/stores/cacheStore';
interface AccountInfo {
name: string;
@@ -71,10 +72,11 @@ export const useAuthStore = create<AuthState>()(
},
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => {
const current = get().activeAccountId;
set({
accounts,
primaryAccountId,
activeAccountId: primaryAccountId,
activeAccountId: current && accounts[current] ? current : primaryAccountId,
apiUrl,
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
@@ -82,9 +84,10 @@ export const useAuthStore = create<AuthState>()(
},
switchAccount: (accountId) => {
const { accounts } = get();
if (accounts[accountId]) {
const { accounts, activeAccountId } = get();
if (accounts[accountId] && accountId !== activeAccountId) {
set({ activeAccountId: accountId });
useCacheStore.getState().clearAll();
}
},
@@ -134,6 +137,7 @@ export const useAuthStore = create<AuthState>()(
tokenExpiresAt: state.tokenExpiresAt,
tokenEndpoint: state.tokenEndpoint,
endSessionEndpoint: state.endSessionEndpoint,
activeAccountId: state.activeAccountId,
}) as AuthState,
},
),
+5
View File
@@ -24,6 +24,7 @@ interface CacheState {
getObjectList: (key: string) => ObjectListEntry[] | undefined;
invalidateObjectList: (key: string) => void;
invalidateAllObjectLists: () => void;
clearAll: () => void;
}
export const useCacheStore = create<CacheState>()((set, get) => ({
@@ -75,4 +76,8 @@ export const useCacheStore = create<CacheState>()((set, get) => ({
invalidateAllObjectLists: () => {
set({ objectLists: {} });
},
clearAll: () => {
set({ displayNames: {}, objectLists: {} });
},
}));
+1 -5
View File
@@ -256,11 +256,7 @@ export interface MassActionSeparator {
}
export type ItemAction =
| ItemActionDelete
| ItemActionSetProperty
| ItemActionQuery
| ItemActionView
| ItemActionSeparator;
ItemActionDelete | ItemActionSetProperty | ItemActionQuery | ItemActionView | ItemActionSeparator;
export interface ItemActionDelete {
type: 'delete';
-1
View File
@@ -8,7 +8,6 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_OAUTH_CLIENT_ID: string;
readonly VITE_ACCESS_TOKEN: string;
readonly VITE_OAUTH_SCOPES: string;
readonly VITE_DEBUG_JMAP?: string;
+4 -2
View File
@@ -1,9 +1,10 @@
/// <reference types="vitest/config" />
import { configDefaults } from 'vitest/config'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
import { version } from './package.json'
import { version } from './package.json' with { type: 'json' }
export default defineConfig({
base: './',
@@ -13,11 +14,12 @@ export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@': path.resolve(import.meta.dirname, './src'),
},
},
test: {
globals: false,
environment: 'happy-dom',
exclude: [...configDefaults.exclude, '**/.ignore/**'],
},
})