Compare commits
12
Commits
v1.0.3
..
d84784c9d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d84784c9d9 | ||
|
|
c40d1c69d8 | ||
|
|
189e270785 | ||
|
|
f18f3012aa | ||
|
|
5eea77346a | ||
|
|
ef99135cae | ||
|
|
5ce2a5acf7 | ||
|
|
e080a6e061 | ||
|
|
574de03e42 | ||
|
|
a7fda8bd6b | ||
|
|
001a1f3a15 | ||
|
|
782dde0573 |
+1
-1
@@ -1,4 +1,4 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
VITE_API_BASE_URL=http://mx.astralmail.org:8080
|
||||
VITE_OAUTH_CLIENT_ID=stalwart-webui
|
||||
#VITE_ACCESS_TOKEN=OPEN_SESAME
|
||||
VITE_OAUTH_SCOPES=
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [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
|
||||
- WebUI version is now displayed when hovering over the logo.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Properly serialize `date` filters when applying them to the list filter.
|
||||
|
||||
## [1.0.5] - 2026-06-21
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Redirect to `/login` when there is no refresh token.
|
||||
- Include required JMAP capabilities in `using`.
|
||||
- Default scopes omit `offline_access`.
|
||||
|
||||
## [1.0.4] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- Align `base32` alphabet with the server.
|
||||
|
||||
## [1.0.3] - 2026-05-05
|
||||
|
||||
### Added
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<base href="/" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Portal</title>
|
||||
</head>
|
||||
@@ -14,4 +14,4 @@
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Generated
+1501
-1531
File diff suppressed because it is too large
Load Diff
+43
-42
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stalwart-webui",
|
||||
"private": true,
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.7",
|
||||
"description": "Stalwart WebUI",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -16,55 +16,56 @@
|
||||
"format:check": "prettier --check src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@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",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.24",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-popover": "^1.1.23",
|
||||
"@radix-ui/react-scroll-area": "^1.2.18",
|
||||
"@radix-ui/react-select": "^2.3.7",
|
||||
"@radix-ui/react-separator": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.3.3",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@radix-ui/react-tabs": "^1.1.21",
|
||||
"@radix-ui/react-tooltip": "^1.2.16",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"i18next": "^26.0.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"otpauth": "^9.5.0",
|
||||
"i18next": "^26.3.6",
|
||||
"lucide-react": "^1.28.0",
|
||||
"otpauth": "^9.5.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
"react-router-dom": "^7.18.2",
|
||||
"recharts": "^3.10.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"@vitejs/plugin-react": "^6.0.5",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"happy-dom": "^20.9.0",
|
||||
"prettier": "^3.8.2",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4",
|
||||
"vitest": "^4.1.4"
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.8.0",
|
||||
"happy-dom": "^20.11.1",
|
||||
"prettier": "^3.9.6",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
+5
-1
@@ -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>
|
||||
);
|
||||
|
||||
@@ -88,9 +88,9 @@ export function BootstrapWizard() {
|
||||
const { obj, sch } = resolved;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accountId = getAccountId(obj.objectName);
|
||||
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
@@ -77,5 +77,9 @@ export default function Logo() {
|
||||
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
return <div className="h-7" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
return <DefaultLogo />;
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBufferedValue } from '@/hooks/useBufferedValue';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,11 +22,7 @@ function BufferedExprInput({
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'onChange' | 'value'
|
||||
>) {
|
||||
const [local, setLocal] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
}, [value]);
|
||||
const [local, setLocal] = useBufferedValue(value);
|
||||
|
||||
const commit = () => {
|
||||
if (local !== value) onCommit(local);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useNavigate, useBlocker } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -166,11 +167,11 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
return Array.from(set);
|
||||
}, [schema, resolved, viewName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema || !resolved) return;
|
||||
const { obj, sch } = resolved;
|
||||
|
||||
if (isCreate) {
|
||||
const [prevCreateInitKey, setPrevCreateInitKey] = useState<typeof resolved | undefined>(undefined);
|
||||
if (isCreate && schema && resolved) {
|
||||
if (resolved !== prevCreateInitKey) {
|
||||
setPrevCreateInitKey(resolved);
|
||||
const { obj, sch } = resolved;
|
||||
const staticFilters = resolved.list?.filtersStatic;
|
||||
if (sch.type === 'multiple') {
|
||||
const variantFromFilter = typeof staticFilters?.['@type'] === 'string' ? staticFilters['@type'] : undefined;
|
||||
@@ -184,13 +185,19 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
setFormData(defaults);
|
||||
setOriginalData(defaults);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else if (prevCreateInitKey !== undefined) {
|
||||
setPrevCreateInitKey(undefined);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema || !resolved || isCreate) return;
|
||||
const { obj, sch } = resolved;
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accountId = getAccountId(obj.objectName);
|
||||
const ids = isSingleton ? ['singleton'] : [objectId];
|
||||
@@ -251,13 +258,14 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
|
||||
const blocker = useBlocker(isDirty && !saving);
|
||||
|
||||
const [pendingNavAfterCreate, setPendingNavAfterCreate] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!pendingNavAfterCreate) return;
|
||||
setPendingNavAfterCreate(false);
|
||||
const navigateAfterCreate = useCallback(() => {
|
||||
flushSync(() => {
|
||||
setOriginalData({ ...formData });
|
||||
setServerCreatedProps(null);
|
||||
});
|
||||
const section = viewToSection[viewName] ?? '';
|
||||
navigate(`/${section}/${viewName}`);
|
||||
}, [pendingNavAfterCreate, viewName, viewToSection, navigate]);
|
||||
}, [formData, viewToSection, viewName, navigate]);
|
||||
|
||||
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
@@ -350,9 +358,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
newFieldErrors[ve.property] = validationErrorMessage(ve);
|
||||
} else if (ve.property) {
|
||||
const detail = ve.value && ve.value.length > 0 ? ve.value : ve.type;
|
||||
setGeneralError((prev) =>
|
||||
prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`,
|
||||
);
|
||||
setGeneralError((prev) => (prev ? `${prev}\n${ve.property}: ${detail}` : `${ve.property}: ${detail}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -736,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" />
|
||||
@@ -930,9 +936,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
open={serverCreatedProps !== null && createdObjectId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setOriginalData({ ...formData });
|
||||
setServerCreatedProps(null);
|
||||
setPendingNavAfterCreate(true);
|
||||
navigateAfterCreate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -965,15 +969,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
|
||||
})()}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setOriginalData({ ...formData });
|
||||
setServerCreatedProps(null);
|
||||
setPendingNavAfterCreate(true);
|
||||
}}
|
||||
>
|
||||
{t('common.continue', 'Continue')}
|
||||
</Button>
|
||||
<Button onClick={navigateAfterCreate}>{t('common.continue', 'Continue')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* 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';
|
||||
@@ -19,8 +20,9 @@ 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 { Calendar } from '@/components/ui/calendar';
|
||||
|
||||
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight } from 'lucide-react';
|
||||
import { Plus, X, Eye, EyeOff, Loader2, Search, Check, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
|
||||
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
DURATION_UNITS,
|
||||
} from '@/lib/durationFormat';
|
||||
import { resolveSchema, resolveVariantForm, resolveObject, buildEmbeddedDefaults } 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';
|
||||
@@ -243,11 +246,7 @@ interface BufferedInputProps extends Omit<React.InputHTMLAttributes<HTMLInputEle
|
||||
}
|
||||
|
||||
function BufferedInput({ value, onCommit, onBlur, onKeyDown, ...rest }: BufferedInputProps) {
|
||||
const [local, setLocal] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
}, [value]);
|
||||
const [local, setLocal] = useBufferedValue(value);
|
||||
|
||||
const commit = () => {
|
||||
if (local !== value) onCommit(local);
|
||||
@@ -278,11 +277,7 @@ interface BufferedTextareaProps extends Omit<React.TextareaHTMLAttributes<HTMLTe
|
||||
}
|
||||
|
||||
function BufferedTextarea({ value, onCommit, onBlur, ...rest }: BufferedTextareaProps) {
|
||||
const [local, setLocal] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value);
|
||||
}, [value]);
|
||||
const [local, setLocal] = useBufferedValue(value);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
@@ -463,8 +458,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
||||
const [localValue, setLocalValue] = useState(() => (value === SECRET_MASK || value === '' ? '' : value));
|
||||
const [isMasked, setIsMasked] = useState(() => value === SECRET_MASK);
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
useResetOnChange(value, () => {
|
||||
if (value === SECRET_MASK || value === '') {
|
||||
setLocalValue('');
|
||||
setIsMasked(value === SECRET_MASK);
|
||||
@@ -472,8 +466,7 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
||||
setLocalValue(value);
|
||||
setIsMasked(false);
|
||||
}
|
||||
}, [value]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
});
|
||||
|
||||
const handleLocalChange = (v: string) => {
|
||||
setLocalValue(v);
|
||||
@@ -513,6 +506,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
rows={4}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
className={visible ? '' : 'tracking-widest'}
|
||||
style={visible ? undefined : ({ WebkitTextSecurity: 'disc' } as React.CSSProperties)}
|
||||
/>
|
||||
@@ -535,6 +532,10 @@ function SecretInput({ value, onChange, readOnly, placeholder, minLength, maxLen
|
||||
placeholder={displayPlaceholder}
|
||||
minLength={minLength}
|
||||
maxLength={maxLength}
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
data-bwignore
|
||||
className="flex-1"
|
||||
/>
|
||||
{toggleBtn}
|
||||
@@ -607,13 +608,7 @@ function BufferedNumberInput({
|
||||
disabled,
|
||||
onCommit,
|
||||
}: BufferedNumberInputProps) {
|
||||
const [local, setLocal] = useState<string>(value != null ? String(value) : '');
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setLocal(value != null ? String(value) : '');
|
||||
}, [value]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
const [local, setLocal] = useBufferedValue(value, (v) => (v != null ? String(v) : ''));
|
||||
|
||||
const commit = () => {
|
||||
if (local === '') {
|
||||
@@ -666,13 +661,11 @@ function SizeInputEditable({ value, onChange, nullable }: Omit<SizeInputProps, '
|
||||
const [unit, setUnit] = useState(initHuman.unit);
|
||||
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
useResetOnChange(value, () => {
|
||||
const h = bytesToHuman(typeof value === 'number' ? value : 0);
|
||||
setUnit(h.unit);
|
||||
setLocalStr(value == null ? '' : String(h.value));
|
||||
}, [value]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
});
|
||||
|
||||
const commit = () => {
|
||||
if (localStr === '') {
|
||||
@@ -771,13 +764,11 @@ function DurationInputEditable({ value, onChange, nullable }: Omit<DurationInput
|
||||
const [unit, setUnit] = useState(initHuman.unit);
|
||||
const [localStr, setLocalStr] = useState<string>(isNull ? '' : String(initHuman.value));
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
useResetOnChange(value, () => {
|
||||
const h = msToHuman(typeof value === 'number' ? value : 0);
|
||||
setUnit(h.unit);
|
||||
setLocalStr(value == null ? '' : String(h.value));
|
||||
}, [value]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
});
|
||||
|
||||
const commit = () => {
|
||||
if (localStr === '') {
|
||||
@@ -842,66 +833,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 toIso = (local: string): string | null => {
|
||||
if (!local) return nullable ? null : '';
|
||||
return new Date(local).toISOString();
|
||||
};
|
||||
|
||||
const [local, setLocal] = useState(() => toLocal(strValue));
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
setLocal(toLocal(strValue));
|
||||
const selected = useMemo(() => {
|
||||
const d = strValue ? new Date(strValue) : null;
|
||||
return d && !isNaN(d.getTime()) ? d : null;
|
||||
}, [strValue]);
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
const commit = () => {
|
||||
const iso = toIso(local);
|
||||
if (iso !== strValue) onChange(iso);
|
||||
const localTime = selected ? selected.toTimeString().slice(0, 5) : '';
|
||||
|
||||
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 || '00:00');
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
@@ -1005,9 +999,9 @@ function BlobField({ value, onChange, readOnly }: BlobFieldProps) {
|
||||
if (!blobId || loaded) return;
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const accountId = getAccountId('x:Blob');
|
||||
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']);
|
||||
@@ -1253,12 +1247,8 @@ function RateField({ value, onChange, readOnly, nullable }: RateFieldProps) {
|
||||
const [localCount, setLocalCount] = useState(String(count));
|
||||
const [localPeriod, setLocalPeriod] = useState(String(human.value));
|
||||
|
||||
useEffect(() => {
|
||||
setLocalCount(String(count));
|
||||
}, [count]);
|
||||
useEffect(() => {
|
||||
setLocalPeriod(String(human.value));
|
||||
}, [human.value]);
|
||||
useResetOnChange(count, () => setLocalCount(String(count)));
|
||||
useResetOnChange(human.value, () => setLocalPeriod(String(human.value)));
|
||||
|
||||
const commitCount = () => {
|
||||
const n = parseInt(localCount, 10);
|
||||
|
||||
@@ -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,6 +54,10 @@ 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>
|
||||
@@ -84,7 +110,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');
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const authenticated = useAuthStore((s) => s.isAuthenticated());
|
||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||
const location = useLocation();
|
||||
if (!accessToken && !bypassToken) {
|
||||
if (!authenticated && !bypassToken) {
|
||||
const originalPath = location.pathname + location.search;
|
||||
return <Navigate to="/login" replace state={{ from: originalPath }} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -74,6 +74,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 +114,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 +124,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 +151,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 +177,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 +199,11 @@ function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, editi
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={onUpsell}
|
||||
activeItemRef={activeItemRef}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</AutoOpenCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,9 +217,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 +245,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 +269,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 +288,11 @@ function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onU
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={onUpsell}
|
||||
activeItemRef={activeItemRef}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</AutoOpenCollapsible>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -264,23 +308,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 +329,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,7 +340,7 @@ export function Sidebar() {
|
||||
|
||||
const handleSectionClick = (target: Layout) => {
|
||||
setActiveSection(target.name);
|
||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||
const canGet = (prefix: string) => permissions.includes(`${prefix}Get`);
|
||||
const first =
|
||||
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
|
||||
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
|
||||
@@ -322,6 +366,7 @@ export function Sidebar() {
|
||||
navigate={navigate}
|
||||
edition={edition}
|
||||
onUpsell={() => setUpsellOpen(true)}
|
||||
activeItemRef={activeItemRef}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -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,
|
||||
@@ -21,15 +20,18 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
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 { 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('-')
|
||||
@@ -54,7 +56,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)
|
||||
@@ -66,12 +79,31 @@ export function TopBar() {
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Link to="/" className="flex shrink-0 items-center">
|
||||
<Logo />
|
||||
</Link>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link to="/" className="flex shrink-0 items-center">
|
||||
<Logo />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t('version.label', 'Stalwart WebUI v{{version}}', { version: __APP_VERSION__ })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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">
|
||||
@@ -81,18 +113,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" />}
|
||||
|
||||
@@ -51,6 +51,8 @@ import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { friendlySetError } from '@/lib/jmapErrors';
|
||||
import { coerceLabel } from '@/lib/objectOptions';
|
||||
import { buildJmapFilter } from '@/lib/listFilter';
|
||||
import { useResetOnChange } from '@/hooks/useBufferedValue';
|
||||
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
@@ -304,6 +306,24 @@ interface SortState {
|
||||
ascending: boolean;
|
||||
}
|
||||
|
||||
function readUrlFilters(): Record<string, string> {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const filters: Record<string, string> = {};
|
||||
params.forEach((value, key) => {
|
||||
if (key.startsWith('f.')) {
|
||||
filters[key.slice(2)] = value;
|
||||
}
|
||||
});
|
||||
return filters;
|
||||
}
|
||||
|
||||
function readUrlSort(): SortState | null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sortParam = params.get('sort');
|
||||
const sortDir = params.get('sortDir');
|
||||
return sortParam ? { field: sortParam, ascending: sortDir !== 'desc' } : null;
|
||||
}
|
||||
|
||||
interface ConfirmAction {
|
||||
label: string;
|
||||
onConfirm: () => void;
|
||||
@@ -351,15 +371,15 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [selectAllMode, setSelectAllMode] = useState(false);
|
||||
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>({});
|
||||
const [filtersOpen, setFiltersOpen] = useState(() => Object.keys(readUrlFilters()).length > 0);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>(readUrlFilters);
|
||||
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
|
||||
|
||||
const [sort, setSort] = useState<SortState | null>(null);
|
||||
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
||||
|
||||
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
useResetOnChange(viewName, () => {
|
||||
setItems([]);
|
||||
setTotal(null);
|
||||
setAnchorStack([]);
|
||||
@@ -368,44 +388,22 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
setSelectAllMode(false);
|
||||
setError(null);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const initialFilters: Record<string, string> = {};
|
||||
params.forEach((value, key) => {
|
||||
if (key.startsWith('f.')) {
|
||||
initialFilters[key.slice(2)] = value;
|
||||
}
|
||||
});
|
||||
const initialFilters = readUrlFilters();
|
||||
setFilterValues(initialFilters);
|
||||
setAppliedFilters(initialFilters);
|
||||
setFiltersOpen(Object.keys(initialFilters).length > 0);
|
||||
setSort(readUrlSort());
|
||||
});
|
||||
|
||||
const sortParam = params.get('sort');
|
||||
const sortDir = params.get('sortDir');
|
||||
setSort(sortParam ? { field: sortParam, ascending: sortDir !== 'desc' } : null);
|
||||
}, [viewName]);
|
||||
|
||||
const objectName = resolved?.obj.objectName;
|
||||
const buildFilter = useCallback((): Record<string, unknown> => {
|
||||
const filter: Record<string, unknown> = {};
|
||||
const list = resolved?.list;
|
||||
if (list?.filtersStatic) {
|
||||
Object.assign(filter, list.filtersStatic);
|
||||
}
|
||||
const opSuffix: Record<string, string> = {
|
||||
eq: '',
|
||||
gt: 'IsGreaterThan',
|
||||
gte: 'IsGreaterThanOrEqual',
|
||||
lt: 'IsLessThan',
|
||||
lte: 'IsLessThanOrEqual',
|
||||
};
|
||||
for (const [key, val] of Object.entries(appliedFilters)) {
|
||||
if (val === '' || val == null) continue;
|
||||
if (key.endsWith('Op')) continue;
|
||||
const op = appliedFilters[`${key}Op`];
|
||||
const suffix = op ? (opSuffix[op] ?? '') : '';
|
||||
filter[`${key}${suffix}`] = val;
|
||||
}
|
||||
return filter;
|
||||
}, [appliedFilters, resolved?.list]);
|
||||
return buildJmapFilter({
|
||||
appliedFilters,
|
||||
filters: resolved?.list?.filters,
|
||||
filtersStatic: resolved?.list?.filtersStatic,
|
||||
isXPrefixed: objectName?.startsWith('x:') ?? false,
|
||||
});
|
||||
}, [appliedFilters, resolved?.list, objectName]);
|
||||
|
||||
const buildSort = useCallback((): Record<string, unknown>[] | undefined => {
|
||||
if (!sort) return undefined;
|
||||
@@ -477,6 +475,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolved?.list) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setAnchorStack([]);
|
||||
setCurrentAnchor(null);
|
||||
fetchData(null);
|
||||
@@ -1216,7 +1215,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">
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
));
|
||||
|
||||
@@ -11,7 +11,7 @@ import { CircleDot, CircleX, Clock } from 'lucide-react';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
|
||||
|
||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
||||
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz792013';
|
||||
|
||||
function intToBase32(input: number | string): string {
|
||||
let n: bigint;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
export function useBufferedValue<T, S = T>(
|
||||
value: T,
|
||||
transform: (v: T) => S = (v) => v as unknown as S,
|
||||
): [S, Dispatch<SetStateAction<S>>] {
|
||||
const [prev, setPrev] = useState(value);
|
||||
const [local, setLocal] = useState<S>(() => transform(value));
|
||||
if (!Object.is(value, prev)) {
|
||||
setPrev(value);
|
||||
setLocal(transform(value));
|
||||
}
|
||||
return [local, setLocal];
|
||||
}
|
||||
|
||||
export function useResetOnChange<K>(key: K, reset: () => void): void {
|
||||
const [prev, setPrev] = useState(key);
|
||||
if (!Object.is(key, prev)) {
|
||||
setPrev(key);
|
||||
reset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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]);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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 };
|
||||
}
|
||||
+12
-1
@@ -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": "user@example.com"
|
||||
},
|
||||
"logo": {
|
||||
@@ -294,6 +301,9 @@
|
||||
"stalwartAlt": "Stalwart Logo"
|
||||
},
|
||||
"logout": "Logout",
|
||||
"version": {
|
||||
"label": "Stalwart WebUI v{{version}}"
|
||||
},
|
||||
"oauth": {
|
||||
"backToLogin": "Back to login",
|
||||
"discoveryFailed": "Discovery failed for \"{{username}}\": {{status}} {{statusText}}",
|
||||
@@ -302,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": {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildJmapFilter } from './listFilter';
|
||||
import type { Filter } from '@/types/schema';
|
||||
|
||||
const dateFilter: Filter = { type: 'date', field: 'timestamp', label: 'Date' };
|
||||
const textFilter: Filter = { type: 'text', field: 'text', label: 'Text' };
|
||||
const intFilter: Filter = { type: 'integer', field: 'size', label: 'Size' };
|
||||
|
||||
describe('buildJmapFilter', () => {
|
||||
it('returns an empty object when nothing is applied', () => {
|
||||
expect(buildJmapFilter({ appliedFilters: {}, isXPrefixed: true })).toEqual({});
|
||||
});
|
||||
|
||||
it('merges filtersStatic', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: {},
|
||||
filtersStatic: { hasErrors: true },
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({ hasErrors: true });
|
||||
});
|
||||
|
||||
it('skips empty and Op-only keys', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { text: '', textOp: 'eq' },
|
||||
filters: [textFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({});
|
||||
});
|
||||
|
||||
it('passes text filters through verbatim', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { text: 'hello' },
|
||||
filters: [textFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({ text: 'hello' });
|
||||
});
|
||||
|
||||
it('applies the operator suffix for integer filters', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { size: '1024', sizeOp: 'gt' },
|
||||
filters: [intFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({ sizeIsGreaterThan: '1024' });
|
||||
});
|
||||
|
||||
describe('date filters (x-prefixed, with operator UI)', () => {
|
||||
it('expands a default date-only equality into a full-day range', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2026-06-17' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({
|
||||
timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z',
|
||||
timestampIsLessThan: '2026-06-18T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an explicit eq operator the same as the default', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'eq' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({
|
||||
timestampIsGreaterThanOrEqual: '2026-06-17T00:00:00Z',
|
||||
timestampIsLessThan: '2026-06-18T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends midnight UTC for the After (gt) operator', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'gt' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T00:00:00Z' });
|
||||
});
|
||||
|
||||
it('appends midnight UTC for the Before (lt) operator', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2026-06-17', timestampOp: 'lt' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({ timestampIsLessThan: '2026-06-17T00:00:00Z' });
|
||||
});
|
||||
|
||||
it('rolls over month boundaries', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2026-06-30' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({
|
||||
timestampIsGreaterThanOrEqual: '2026-06-30T00:00:00Z',
|
||||
timestampIsLessThan: '2026-07-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls over year boundaries', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2026-12-31' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({
|
||||
timestampIsGreaterThanOrEqual: '2026-12-31T00:00:00Z',
|
||||
timestampIsLessThan: '2027-01-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles leap-year February', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2028-02-28' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({
|
||||
timestampIsGreaterThanOrEqual: '2028-02-28T00:00:00Z',
|
||||
timestampIsLessThan: '2028-02-29T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes a full datetime through with only the operator suffix applied', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2026-06-17T08:30:00Z', timestampOp: 'gt' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: true,
|
||||
});
|
||||
expect(filter).toEqual({ timestampIsGreaterThan: '2026-06-17T08:30:00Z' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('date filters (non-x-prefixed, no operator UI)', () => {
|
||||
it('appends midnight UTC as an exact match without range expansion', () => {
|
||||
const filter = buildJmapFilter({
|
||||
appliedFilters: { timestamp: '2026-06-17' },
|
||||
filters: [dateFilter],
|
||||
isXPrefixed: false,
|
||||
});
|
||||
expect(filter).toEqual({ timestamp: '2026-06-17T00:00:00Z' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import type { Filter } from '@/types/schema';
|
||||
|
||||
const OP_SUFFIX: Record<string, string> = {
|
||||
eq: '',
|
||||
gt: 'IsGreaterThan',
|
||||
gte: 'IsGreaterThanOrEqual',
|
||||
lt: 'IsLessThan',
|
||||
lte: 'IsLessThanOrEqual',
|
||||
};
|
||||
|
||||
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function utcDayStart(date: string): string {
|
||||
return `${date}T00:00:00Z`;
|
||||
}
|
||||
|
||||
function utcNextDayStart(date: string): string {
|
||||
const dt = new Date(`${date}T00:00:00Z`);
|
||||
dt.setUTCDate(dt.getUTCDate() + 1);
|
||||
const y = dt.getUTCFullYear();
|
||||
const m = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(dt.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}T00:00:00Z`;
|
||||
}
|
||||
|
||||
function applyDateFilter(
|
||||
filter: Record<string, unknown>,
|
||||
field: string,
|
||||
value: string,
|
||||
op: string | undefined,
|
||||
isXPrefixed: boolean,
|
||||
): void {
|
||||
if (!DATE_ONLY.test(value)) {
|
||||
const suffix = isXPrefixed && op ? (OP_SUFFIX[op] ?? '') : '';
|
||||
filter[`${field}${suffix}`] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isXPrefixed) {
|
||||
filter[field] = utcDayStart(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const effectiveOp = op ?? 'eq';
|
||||
if (effectiveOp === 'eq') {
|
||||
filter[`${field}IsGreaterThanOrEqual`] = utcDayStart(value);
|
||||
filter[`${field}IsLessThan`] = utcNextDayStart(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const suffix = OP_SUFFIX[effectiveOp] ?? '';
|
||||
filter[`${field}${suffix}`] = utcDayStart(value);
|
||||
}
|
||||
|
||||
export interface BuildJmapFilterArgs {
|
||||
appliedFilters: Record<string, string>;
|
||||
filters?: Filter[];
|
||||
filtersStatic?: Record<string, unknown>;
|
||||
isXPrefixed: boolean;
|
||||
}
|
||||
|
||||
export function buildJmapFilter({
|
||||
appliedFilters,
|
||||
filters,
|
||||
filtersStatic,
|
||||
isXPrefixed,
|
||||
}: BuildJmapFilterArgs): Record<string, unknown> {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (filtersStatic) {
|
||||
Object.assign(filter, filtersStatic);
|
||||
}
|
||||
|
||||
const dateFields = new Set((filters ?? []).filter((f) => f.type === 'date').map((f) => f.field));
|
||||
|
||||
for (const [key, val] of Object.entries(appliedFilters)) {
|
||||
if (val === '' || val == null) continue;
|
||||
if (key.endsWith('Op')) continue;
|
||||
|
||||
const op = appliedFilters[`${key}Op`];
|
||||
|
||||
if (dateFields.has(key)) {
|
||||
applyDateFilter(filter, key, val, op, isXPrefixed);
|
||||
continue;
|
||||
}
|
||||
|
||||
const suffix = op ? (OP_SUFFIX[op] ?? '') : '';
|
||||
filter[`${key}${suffix}`] = val;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
@@ -97,7 +97,7 @@ async function fetchObjectList(viewOrObjectName: string, schema: Schema): Promis
|
||||
const accountId = getAccountId(objectName);
|
||||
const displayProp = getDisplayProperty(schema, viewOrObjectName);
|
||||
|
||||
let items: Array<Record<string, unknown>> = [];
|
||||
let items: Array<Record<string, unknown>>;
|
||||
try {
|
||||
const result = await jmapQueryAllAndGet(
|
||||
objectName,
|
||||
|
||||
+1
-1
@@ -12,8 +12,8 @@ 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';
|
||||
|
||||
|
||||
@@ -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'));
|
||||
+65
-42
@@ -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,12 @@ 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';
|
||||
|
||||
const BootstrapWizard = lazy(() =>
|
||||
import('@/components/bootstrap/BootstrapWizard').then((m) => ({ default: m.BootstrapWizard })),
|
||||
);
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -69,9 +74,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 +85,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 +166,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 +194,46 @@ 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);
|
||||
return;
|
||||
}
|
||||
|
||||
if (section) {
|
||||
setActiveSection(section);
|
||||
const ownLayout = layouts.find((l) => l.name.toLowerCase() === section.toLowerCase());
|
||||
const ownLink = ownLayout
|
||||
? (findFirstAccessibleLinkInLayout(schema, ownLayout, edition, canGet, hasPerm) ??
|
||||
findFirstVisibleLinkInLayout(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 +258,9 @@ export default function AdminPanel() {
|
||||
if (isBootstrapMode) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BootstrapWizard />
|
||||
<Suspense fallback={<LoadingFallback fullScreen />}>
|
||||
<BootstrapWizard />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -250,7 +273,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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+11
-5
@@ -107,11 +107,17 @@ export async function apiFetch(path: string, options?: RequestInit): Promise<Res
|
||||
|
||||
let response = await makeRequest();
|
||||
|
||||
if (response.status === 401 && useAuthStore.getState().refreshToken) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
response = await makeRequest();
|
||||
} catch {
|
||||
if (response.status === 401) {
|
||||
if (useAuthStore.getState().refreshToken) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
response = await makeRequest();
|
||||
} catch {
|
||||
throw new ApiError(401, 'Unauthorized', null);
|
||||
}
|
||||
} else {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = `${getBasePath()}/login`;
|
||||
throw new ApiError(401, 'Unauthorized', null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,8 +125,7 @@ function getRedirectUri(): string {
|
||||
}
|
||||
|
||||
export async function startAuthFlow(username: string, returnUrl?: string | null): Promise<void> {
|
||||
const { authorization_endpoint, token_endpoint, end_session_endpoint, scopes_supported } =
|
||||
await discover(username);
|
||||
const { authorization_endpoint, token_endpoint, end_session_endpoint, scopes_supported } = await discover(username);
|
||||
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier);
|
||||
@@ -167,7 +166,7 @@ export async function startAuthFlow(username: string, returnUrl?: string | null)
|
||||
if (SCOPES && SCOPES.length > 0) {
|
||||
scope = SCOPES;
|
||||
} else if (scopes_supported?.includes('openid')) {
|
||||
scope = ['openid', 'email', 'profile'].filter((s) => scopes_supported.includes(s)).join(' ');
|
||||
scope = ['openid', 'email', 'profile', 'offline_access'].filter((s) => scopes_supported.includes(s)).join(' ');
|
||||
} else {
|
||||
scope = '';
|
||||
}
|
||||
|
||||
@@ -10,7 +10,17 @@ import { logJmapExchange } from '@/lib/debug';
|
||||
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
|
||||
import type { Schema } from '@/types/schema';
|
||||
|
||||
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
|
||||
const JMAP_USING = [
|
||||
'urn:ietf:params:jmap:core',
|
||||
'urn:stalwart:jmap',
|
||||
'urn:ietf:params:jmap:blob',
|
||||
'urn:ietf:params:jmap:mail',
|
||||
'urn:ietf:params:jmap:calendars',
|
||||
'urn:ietf:params:jmap:contacts',
|
||||
'urn:ietf:params:jmap:principals',
|
||||
'urn:ietf:params:jmap:sieve',
|
||||
'urn:ietf:params:jmap:vacationresponse',
|
||||
];
|
||||
|
||||
export function getAccountId(objectType: string): string {
|
||||
const { primaryAccountId, activeAccountId } = useAuthStore.getState();
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
Vendored
+2
@@ -18,3 +18,5 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
+5
-1
@@ -3,13 +3,17 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
import { version } from './package.json' with { type: 'json' }
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(version),
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@': path.resolve(import.meta.dirname, './src'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user