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