Initial commit

This commit is contained in:
Maurus Decimus
2026-04-20 15:02:57 +02:00
parent e72c9614f1
commit 4470616775
115 changed files with 26207 additions and 21 deletions
+18
View File
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Outlet } from 'react-router-dom';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { Toaster } from '@/components/ui/toaster';
export default function App() {
return (
<ErrorBoundary>
<Outlet />
<Toaster />
</ErrorBoundary>
);
}
@@ -0,0 +1,532 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArrowLeft, ArrowRight, Check, Copy, Loader2, Rocket } from 'lucide-react';
import { useSchemaStore } from '@/stores/schemaStore';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { FormEditionContext } from '@/components/forms/FormEditionContext';
import { DefaultLogo } from '@/components/common/Logo';
import { toast } from '@/hooks/use-toast';
import { resolveObject, resolveSchema, resolveForm, buildCreateDefaults, deepMerge } from '@/lib/schemaResolver';
import { calculateJmapPatch } from '@/lib/jmapPatch';
import { jmapGet, jmapSet, getAccountId } from '@/services/jmap/client';
import { friendlySetError } from '@/lib/jmapErrors';
import type { Field, Fields, Form, FormField } from '@/types/schema';
import type { JmapSetError, JmapSetResponse } from '@/types/jmap';
const BOOTSTRAP_VIEW = 'x:Bootstrap';
interface RenderableField {
formField: FormField;
field: Field;
}
export function BootstrapWizard() {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const resolved = useMemo(() => {
if (!schema) return null;
const obj = resolveObject(schema, BOOTSTRAP_VIEW);
if (!obj) return null;
const sch = resolveSchema(schema, obj.objectName);
if (!sch || sch.type !== 'single') return null;
const form = resolveForm(schema, BOOTSTRAP_VIEW, obj.objectName, sch.schemaName);
return { obj, sch, fields: sch.fields, form };
}, [schema]);
const [formData, setFormData] = useState<Record<string, unknown>>({});
const [originalData, setOriginalData] = useState<Record<string, unknown>>({});
const [currentPage, setCurrentPage] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const [generalError, setGeneralError] = useState<string | null>(null);
const [successExtras, setSuccessExtras] = useState<Record<string, unknown> | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const sections = useMemo((): { title?: string; subtitle?: string; fields: RenderableField[] }[] => {
if (!resolved) return [];
const { fields, form } = resolved;
if (!form) return [];
return form.sections
.map((section) => {
const renderableFields: RenderableField[] = [];
for (const ff of section.fields) {
const field = fields.properties[ff.name];
if (!field) continue;
if (field.update === 'serverSet') continue;
if (field.enterprise) continue;
renderableFields.push({ formField: ff, field });
}
return { title: section.title, fields: renderableFields };
})
.filter((s) => s.fields.length > 0);
}, [resolved]);
const fetchProperties = useMemo((): string[] => {
if (!resolved) return ['id'];
const set = new Set<string>(['id']);
for (const section of sections) {
for (const rf of section.fields) set.add(rf.formField.name);
}
return Array.from(set);
}, [resolved, sections]);
useEffect(() => {
if (!schema || !resolved) return;
const { obj, sch } = resolved;
const ctrl = new AbortController();
setLoading(true);
(async () => {
try {
const accountId = getAccountId(obj.objectName);
const responses = await jmapGet(obj.objectName, accountId, ['singleton'], fetchProperties, ctrl.signal);
if (ctrl.signal.aborted) return;
const server = (responses[0]?.[1]?.list as Array<Record<string, unknown>> | undefined)?.[0] ?? {};
const defaults = buildCreateDefaults(schema, obj, sch);
const seeded = deepMerge(defaults, server);
setFormData(seeded);
setOriginalData({});
} catch (err) {
if (ctrl.signal.aborted) return;
if (err instanceof DOMException && err.name === 'AbortError') return;
setGeneralError(
err instanceof Error ? err.message : t('bootstrap.failedToLoad', 'Failed to load bootstrap state.'),
);
} finally {
if (!ctrl.signal.aborted) setLoading(false);
}
})();
return () => {
ctrl.abort();
};
}, [schema, resolved, fetchProperties, t]);
const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
setFieldErrors((prev) => {
if (!prev[fieldName]) return prev;
const copy = { ...prev };
delete copy[fieldName];
return copy;
});
}, []);
const validateSectionRequired = useCallback(
(sectionFields: RenderableField[], data: Record<string, unknown>): Record<string, string> => {
const errors: Record<string, string> = {};
for (const { formField, field } of sectionFields) {
if (field.update === 'serverSet' || field.update === 'immutable') continue;
const fieldType = field.type;
const eligible =
fieldType.type === 'string' ||
fieldType.type === 'number' ||
fieldType.type === 'utcDateTime' ||
fieldType.type === 'enum' ||
fieldType.type === 'blobId' ||
fieldType.type === 'objectId';
if (!eligible) continue;
if ('nullable' in fieldType && fieldType.nullable) continue;
const value = data[formField.name];
const isEmpty = value === undefined || value === null || (typeof value === 'string' && value === '');
if (isEmpty) errors[formField.name] = t('form.required', 'This field is required.');
}
return errors;
},
[t],
);
const applySetError = useCallback(
(error: JmapSetError): number | null => {
setGeneralError(friendlySetError(error));
const fieldToPage = new Map<string, number>();
sections.forEach((section, idx) => {
for (const rf of section.fields) {
if (!fieldToPage.has(rf.formField.name)) fieldToPage.set(rf.formField.name, idx);
}
});
const newErrors: Record<string, string> = {};
let earliest: number | null = null;
const record = (topLevel: string, msg: string) => {
if (!fieldToPage.has(topLevel)) return;
newErrors[topLevel] = msg;
const idx = fieldToPage.get(topLevel)!;
if (earliest === null || idx < earliest) earliest = idx;
};
if (error.properties) {
for (const prop of error.properties) {
const top = prop.split('/')[0];
record(top, error.description ?? t('form.invalidValue', 'Invalid value.'));
}
}
if (error.validationErrors) {
for (const ve of error.validationErrors) {
const top = ve.property?.split('/')[0] ?? '';
if (!top) continue;
const msg =
ve.type === 'Required'
? t('form.required', 'This field is required.')
: ve.type === 'MaxLength'
? t('form.maxLengthIs', 'Maximum length is {{max}}.', { max: ve.required })
: ve.type === 'MinLength'
? t('form.minLengthIs', 'Minimum length is {{min}}.', { min: ve.required })
: ve.type === 'MaxValue'
? t('form.maxValueIs', 'Maximum value is {{max}}.', { max: ve.required })
: ve.type === 'MinValue'
? t('form.minValueIs', 'Minimum value is {{min}}.', { min: ve.required })
: t('form.invalidValue', 'Invalid value.');
record(top, msg);
}
}
if (Object.keys(newErrors).length > 0) setFieldErrors(newErrors);
return earliest;
},
[sections, t],
);
const canGoBack = currentPage > 0;
const isLastPage = currentPage === sections.length - 1;
const handleNext = useCallback(() => {
const current = sections[currentPage];
if (!current) return;
const errs = validateSectionRequired(current.fields, formData);
if (Object.keys(errs).length > 0) {
setFieldErrors(errs);
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
return;
}
setGeneralError(null);
setFieldErrors({});
setCurrentPage((p) => Math.min(p + 1, sections.length - 1));
}, [currentPage, sections, formData, validateSectionRequired, t]);
const handleBack = useCallback(() => {
setGeneralError(null);
setFieldErrors({});
setCurrentPage((p) => Math.max(p - 1, 0));
}, []);
const handleSubmit = useCallback(async () => {
if (!resolved) return;
const { obj } = resolved;
let firstInvalid: number | null = null;
const allErrors: Record<string, string> = {};
sections.forEach((section, idx) => {
const errs = validateSectionRequired(section.fields, formData);
for (const [k, v] of Object.entries(errs)) {
allErrors[k] = v;
if (firstInvalid === null) firstInvalid = idx;
}
});
if (firstInvalid !== null) {
setFieldErrors(allErrors);
setGeneralError(t('form.correctErrorsBelow', 'Please correct the errors below.'));
setCurrentPage(firstInvalid);
return;
}
setSaving(true);
setGeneralError(null);
setFieldErrors({});
try {
const patch = calculateJmapPatch(originalData, formData);
const accountId = getAccountId(obj.objectName);
const responses = await jmapSet(obj.objectName, accountId, {
update: { singleton: patch },
});
const setResult = responses[responses.length - 1]?.[1] as unknown as JmapSetResponse;
if (setResult.updated && 'singleton' in setResult.updated) {
setSuccessExtras(setResult.updated.singleton ?? {});
} else if (setResult.notUpdated && setResult.notUpdated.singleton) {
const earliest = applySetError(setResult.notUpdated.singleton);
if (earliest !== null) setCurrentPage(earliest);
} else {
setGeneralError(t('bootstrap.noConfirm', 'The server did not confirm the update.'));
}
} catch (err) {
setGeneralError(
err instanceof Error ? err.message : t('bootstrap.failedToComplete', 'Failed to complete setup.'),
);
} finally {
setSaving(false);
}
}, [resolved, sections, formData, originalData, validateSectionRequired, applySetError, t]);
const handleCopy = useCallback(
async (key: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
setCopied(key);
setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500);
} catch {
toast({
title: t('bootstrap.copyFailed', 'Copy failed'),
description: t('bootstrap.clipboardBlocked', 'Your browser blocked clipboard access.'),
variant: 'destructive',
});
}
},
[t],
);
if (!schema || !resolved) {
return (
<WizardShell>
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
{t('form.loadingSchema', 'Loading schema...')}
</div>
</WizardShell>
);
}
if (loading) {
return (
<WizardShell>
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
{t('bootstrap.loadingSetup', 'Loading setup...')}
</div>
</WizardShell>
);
}
if (successExtras !== null) {
return (
<WizardShell>
<SuccessScreen
extras={successExtras}
fields={resolved.fields}
labelsByName={labelsByFieldName(resolved.form)}
onCopy={handleCopy}
copied={copied}
/>
</WizardShell>
);
}
if (sections.length === 0) {
return (
<WizardShell>
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
{t('bootstrap.emptyForm', 'Setup form is empty. The server did not return any bootstrap fields.')}
</div>
</WizardShell>
);
}
const current = sections[currentPage];
return (
<WizardShell>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.welcome', 'Welcome to Stalwart')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('bootstrap.welcomeSubtitle', "Let's get your server set up.")}
</p>
</div>
<ProgressIndicator total={sections.length} current={currentPage} />
{generalError && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
<p className="text-sm text-destructive whitespace-pre-line">{generalError}</p>
</div>
)}
<Card>
{current.title && (
<CardHeader>
<CardTitle className="text-base">{current.title}</CardTitle>
</CardHeader>
)}
<CardContent className={current.title ? '' : 'pt-6'}>
<div className="space-y-6">
<FormEditionContext.Provider value="oss">
{current.fields.map(({ formField, field }) => (
<FieldWidget
key={formField.name}
field={field}
formField={formField}
value={formData[formField.name]}
onChange={(v) => handleFieldChange(formField.name, v)}
readOnly={saving}
error={fieldErrors[formField.name]}
schema={schema}
/>
))}
</FormEditionContext.Provider>
</div>
</CardContent>
</Card>
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={handleBack} disabled={!canGoBack || saving}>
<ArrowLeft className="h-4 w-4 mr-2" />
{t('common.back', 'Back')}
</Button>
<div className="text-xs text-muted-foreground">
{t('bootstrap.stepOf', 'Step {{current}} of {{total}}', {
current: currentPage + 1,
total: sections.length,
})}
</div>
{isLastPage ? (
<Button type="button" onClick={handleSubmit} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
{t('bootstrap.finishSetup', 'Finish setup')}
</Button>
) : (
<Button type="button" onClick={handleNext} disabled={saving}>
{t('common.next', 'Next')}
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
)}
</div>
</div>
</WizardShell>
);
}
function WizardShell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-content-background">
<header className="flex items-center px-6 py-4 border-b bg-background">
<DefaultLogo />
</header>
<main className="flex-1 overflow-auto p-6">
<div className="mx-auto max-w-3xl">{children}</div>
</main>
</div>
);
}
function ProgressIndicator({ total, current }: { total: number; current: number }) {
const { t } = useTranslation();
return (
<div
className="flex items-center gap-2"
aria-label={t('bootstrap.stepOf', 'Step {{current}} of {{total}}', { current: current + 1, total })}
>
{Array.from({ length: total }, (_, i) => (
<div
key={i}
className={`h-2 w-2 rounded-full transition-colors ${
i < current ? 'bg-primary' : i === current ? 'bg-primary' : 'bg-muted-foreground/20'
}`}
/>
))}
</div>
);
}
function SuccessScreen({
extras,
fields,
labelsByName,
onCopy,
copied,
}: {
extras: Record<string, unknown>;
fields: Fields;
labelsByName: Map<string, string>;
onCopy: (key: string, value: string) => void;
copied: string | null;
}) {
const { t } = useTranslation();
const entries = Object.entries(extras).filter(([k, v]) => {
if (k === 'id' || k === 'blobId' || k === '@type') return false;
return typeof v === 'string' && v.length > 0;
}) as [string, string][];
const hasCredentials = entries.length > 0;
return (
<div className="space-y-6">
<div className="flex flex-col items-start gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<Check className="h-5 w-5 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-tight">{t('bootstrap.complete', 'Setup complete')}</h2>
<p className="text-sm text-muted-foreground mt-1">
{hasCredentials
? t(
'bootstrap.credentialsCreated',
'Your administrator account has been created. Write these down now: the password will not be shown again.',
)
: t('bootstrap.configuredSuccessfully', 'Stalwart has been configured successfully.')}
</p>
</div>
</div>
{hasCredentials && (
<Card>
<CardContent className="pt-6 space-y-4">
{entries.map(([key, value]) => {
const field = fields.properties[key];
const label = labelsByName.get(key) ?? field?.description?.split('\n')[0] ?? key;
return (
<div key={key} className="space-y-1.5">
<div className="text-sm font-medium">{label}</div>
<div className="flex gap-2">
<code className="flex-1 rounded bg-muted p-2 text-sm break-all select-all">{value}</code>
<Button type="button" variant="outline" size="sm" onClick={() => onCopy(key, value)}>
{copied === key ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)}
<div className="rounded-md border border-primary/20 bg-primary/5 p-4">
<p className="text-sm">
<span className="font-medium">{t('bootstrap.nextStepLabel', 'Next step:')}</span>{' '}
{t(
'bootstrap.nextStepBody',
'restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.',
)}
</p>
</div>
</div>
);
}
function labelsByFieldName(form: Form | null): Map<string, string> {
const map = new Map<string, string>();
if (!form) return map;
for (const section of form.sections) {
for (const ff of section.fields) map.set(ff.name, ff.label);
}
return map;
}
export default BootstrapWizard;
@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useTranslation } from 'react-i18next';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface EnterpriseUpsellProps {
open: boolean;
onClose: () => void;
}
export function EnterpriseUpsell({ open, onClose }: EnterpriseUpsellProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('enterprise.trialTitle')}</DialogTitle>
<DialogDescription>{t('enterprise.trialDescription')}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t('common.close')}
</Button>
<Button asChild>
<a href="https://license.stalw.art/trial" target="_blank" rel="noopener noreferrer">
{t('enterprise.trialButton')}
</a>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+240
View File
@@ -0,0 +1,240 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search, List, Settings, Plus } from 'lucide-react';
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { resolveObject } from '@/lib/schemaResolver';
import type { Schema } from '@/types/schema';
const MAX_RESULTS = 15;
const TYPE_ORDER: Record<SearchIndexEntry['type'], number> = {
link: 0,
form: 1,
field: 2,
};
function getObjectKind(schema: Schema, viewName: string): 'singleton' | 'object' | null {
const resolved = resolveObject(schema, viewName);
if (!resolved) return null;
return resolved.objectType.type === 'singleton' ? 'singleton' : 'object';
}
function getActionInfo(
entryType: SearchIndexEntry['type'],
objectKind: 'singleton' | 'object' | null,
t: (key: string, fallback: string) => string,
): { label: string; Icon: typeof List } {
if (entryType === 'link') {
return objectKind === 'singleton'
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
: { label: t('globalSearch.list', 'List'), Icon: List };
}
return objectKind === 'singleton'
? { label: t('globalSearch.settings', 'Settings'), Icon: Settings }
: { label: t('globalSearch.create', 'Create'), Icon: Plus };
}
function getNavigationPath(
entryType: SearchIndexEntry['type'],
objectKind: 'singleton' | 'object' | null,
section: string,
viewName: string,
): string {
const encodedView = viewName;
if (entryType === 'link') {
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}`;
}
return objectKind === 'singleton' ? `/${section}/${encodedView}/singleton` : `/${section}/${encodedView}/new`;
}
function friendlyName(viewName: string): string {
const stripped = viewName.replace(/^x:/, '');
const parts = stripped.split('/');
return parts[parts.length - 1];
}
export function GlobalSearch() {
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);
},
[schema, navigate],
);
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="flex flex-1 items-center justify-center px-4" ref={containerRef}>
<div className="relative w-full max-w-md">
<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}
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>
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getApiBaseUrl } from '@/services/api';
export function DefaultLogo() {
const { t } = useTranslation();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="95 84 500 90"
aria-label={t('logo.stalwartAlt', 'Stalwart Logo')}
className="h-7 w-auto max-w-[320px]"
>
<path
className="fill-current"
d="M227.8 143.6c.3 4.2 2.1 7.6 5.1 10.1 3.1 2.5 7.1 3.8 12.1 3.8 4.3 0 7.9-.9 10.5-2.8 2.7-1.9 4-4.5 4-7.8 0-2.4-.7-4.3-2.2-5.7-1.5-1.4-3.4-2.5-6-3.2-2.5-.7-6-1.5-10.6-2.3-4.6-.8-8.6-1.9-11.9-3.2-3.3-1.3-6-3.3-8.1-6.1-2.1-2.7-3.1-6.3-3.1-10.7 0-4.1 1.1-7.7 3.2-10.9s5.1-5.7 9-7.4c3.8-1.8 8.2-2.6 13.2-2.6 5.1 0 9.6 1 13.7 2.9 4 1.9 7.2 4.5 9.5 7.8s3.6 7.1 3.8 11.4h-11.5c-.4-3.7-2-6.6-4.8-8.9-2.8-2.2-6.3-3.4-10.6-3.4-4.1 0-7.5.9-9.9 2.7-2.5 1.8-3.7 4.3-3.7 7.6 0 2.3.7 4.1 2.2 5.5 1.5 1.4 3.4 2.4 5.9 3.1 2.4.7 5.9 1.4 10.5 2.2 4.6.8 8.6 1.9 11.9 3.3 3.3 1.4 6 3.4 8.2 6 2.1 2.6 3.2 6.1 3.2 10.5 0 4.2-1.1 8-3.4 11.3-2.2 3.3-5.4 5.9-9.4 7.8-4 1.9-8.6 2.8-13.7 2.8-5.6 0-10.6-1-14.9-3.1-4.3-2-7.6-4.9-10-8.5-2.4-3.6-3.7-7.8-3.7-12.5l11.5.3zM278.5 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5-2.2-2.3-3.4-5.9-3.4-10.7v-50.5zM356.8 114.6v52.2h-9.7l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8s8.2-1.6 11-4.8zM365.5 97.5l11-2.1v71.3h-11V97.5zM380.3 114.6h11.6l11.9 39.9 11.9-39.9h10.1l11.4 39.9 12.3-39.9h11.2l-17.3 52.2h-11.8l-11-35.5-11.4 35.5-11.9.1-17-52.3zM513.7 114.6v52.2H504l-1.2-7.9c-1.8 2.6-4.2 4.7-7 6.2-2.9 1.6-6.2 2.3-10 2.3-4.8 0-9-1.1-12.7-3.2-3.7-2.1-6.7-5.2-8.8-9.3-2.1-4-3.2-8.8-3.2-14.2 0-5.3 1.1-10 3.2-14s5.1-7.2 8.8-9.4c3.7-2.2 7.9-3.3 12.6-3.3 3.9 0 7.2.7 10.1 2.2 2.9 1.5 5.2 3.5 6.9 6.1l1.3-7.6h9.7zm-15.1 38.7c2.8-3.2 4.2-7.3 4.2-12.4 0-5.2-1.4-9.4-4.2-12.6-2.8-3.3-6.5-4.9-11-4.9-4.6 0-8.2 1.6-11 4.8-2.8 3.2-4.2 7.4-4.2 12.5 0 5.2 1.4 9.4 4.2 12.6 2.8 3.2 6.5 4.8 11 4.8 4.6 0 8.2-1.6 11-4.8zM551.3 114.6v10.3h-4.9c-4.6 0-7.8 1.5-9.9 4.4-2 3-3.1 6.7-3.1 11.3v26.2h-11v-52.2h9.8l1.2 7.8c1.5-2.4 3.4-4.4 5.8-5.8 2.4-1.4 5.6-2.1 9.6-2.1h2.5zM556.3 102.1l11-2.1v14.6h12.6v9.7h-12.6v27.2c0 2 .4 3.5 1.2 4.3.8.9 2.2 1.3 4.2 1.3h8.4v9.7h-10.6c-5 0-8.6-1.2-10.8-3.5s-3.4-5.9-3.4-10.7v-50.5z"
/>
<path
fill="#db2d54"
d="M149.1 84.7h-4.8l-44.8 25.9v8.3l44.8 25.9h4.8l44.8-25.9v-8.3l-44.8-25.9zm32.9 30h-35.3V94.4l35.3 20.3zm-35.3 20.4-35.3-20.4 27-15.6v20.2l6.3 3.6h22.9l-20.9 12.2zM99.5 129.9v11l44.8 25.9h4.8l44.8-25.9v-11l-47.2 27.3zM187.3 166.8l6.6-3.8v-11l-25.7 14.8zM99.5 163l6.6 3.8h19.1L99.5 152z"
/>
</svg>
);
}
export default function Logo() {
const { t } = useTranslation();
const [logoUrl, setLogoUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const controller = new AbortController();
async function fetchLogo() {
try {
const response = await fetch(`${getApiBaseUrl()}/logo`, {
signal: controller.signal,
});
const contentType = response.headers.get('content-type') ?? '';
if (response.ok && contentType.startsWith('image/')) {
const blob = await response.blob();
if (!controller.signal.aborted) {
const url = URL.createObjectURL(blob);
setLogoUrl(url);
}
} else {
if (!controller.signal.aborted) setFailed(true);
}
} catch {
if (!controller.signal.aborted) setFailed(true);
}
}
fetchLogo();
return () => {
controller.abort();
};
}, []);
useEffect(() => {
return () => {
if (logoUrl) {
URL.revokeObjectURL(logoUrl);
}
};
}, [logoUrl]);
if (logoUrl && !failed) {
return <img src={logoUrl} alt={t('logo.alt', 'Logo')} className="h-7 w-auto max-w-[220px] object-contain" />;
}
return <DefaultLogo />;
}
+135
View File
@@ -0,0 +1,135 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Search, X } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useObjectList, useObjectLabel, useNoPermissionMessage } from '@/lib/objectOptions';
import type { Schema } from '@/types/schema';
interface ObjectPickerProps {
schema: Schema;
objectName: string;
value: string;
onChange: (id: string) => void;
onClear?: () => void;
placeholder?: string;
}
export function ObjectPicker({ schema, objectName, value, onChange, onClear, placeholder }: ObjectPickerProps) {
const { t } = useTranslation();
const list = useObjectList(objectName, schema);
const fromList = list.options.find((o) => o.id === value)?.label;
const { label: cheapLabel, loading: labelLoading } = useObjectLabel(
objectName,
fromList ? null : value || null,
schema,
);
const display = fromList ?? cheapLabel;
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const noPermissionMessage = useNoPermissionMessage(schema, objectName);
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (next) void list.ensureLoaded();
};
return (
<div className="flex items-center gap-2 flex-wrap">
{value && (
<Badge variant="secondary" className="gap-1 pr-1.5 text-sm">
{labelLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : (display ?? value)}
{onClear && (
<button
type="button"
onClick={onClear}
className="ml-1 rounded-full hover:bg-muted-foreground/20 p-0.5"
aria-label={t('common.clear', 'Clear')}
>
<X className="h-3 w-3" />
</button>
)}
</Badge>
)}
{!value && placeholder && <span className="text-sm text-muted-foreground">{placeholder}</span>}
{noPermissionMessage ? (
<TooltipProvider>
<Tooltip open={tooltipOpen} onOpenChange={setTooltipOpen}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 cursor-not-allowed opacity-60"
aria-label={t('common.search', 'Search')}
aria-disabled="true"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setTooltipOpen(true);
}}
>
<Search className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{noPermissionMessage}</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label={t('common.search', 'Search')}
>
<Search className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-72" align="start">
<Command>
<CommandInput placeholder={t('common.searchPlaceholder', 'Search...')} />
<CommandList>
{list.loading && (
<div className="flex items-center justify-center py-6 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('common.loading', 'Loading...')}
</div>
)}
{!list.loading && <CommandEmpty>{t('common.noResultsDot', 'No results.')}</CommandEmpty>}
<CommandGroup>
{list.options.map((opt) => (
<CommandItem
key={opt.id}
value={opt.id}
keywords={[opt.label]}
onSelect={() => {
onChange(opt.id);
setOpen(false);
}}
>
{opt.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
);
}
@@ -0,0 +1,175 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { X, Plus } from 'lucide-react';
function BufferedExprInput({
value,
onCommit,
...rest
}: { value: string; onCommit: (v: string) => void } & Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'value'
>) {
const [local, setLocal] = useState(value);
useEffect(() => {
setLocal(value);
}, [value]);
const commit = () => {
if (local !== value) onCommit(local);
};
return (
<Input
{...rest}
value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
}}
/>
);
}
interface ExpressionEditorProps {
value: {
match: Record<string, { if: string; then: string }>;
else: string;
};
onChange: (value: { match: Record<string, { if: string; then: string }>; else: string }) => void;
readOnly?: boolean;
}
export function ExpressionEditor({ value, onChange, readOnly = false }: ExpressionEditorProps) {
const { t } = useTranslation();
const [nextIndex, setNextIndex] = useState(() => {
const keys = Object.keys(value.match).map(Number).filter(Number.isFinite);
return keys.length > 0 ? Math.max(...keys) + 1 : 0;
});
const matchEntries = Object.entries(value.match);
const handleConditionChange = (key: string, field: 'if' | 'then', fieldValue: string) => {
onChange({
...value,
match: {
...value.match,
[key]: { ...value.match[key], [field]: fieldValue },
},
});
};
const handleElseChange = (elseValue: string) => {
onChange({ ...value, else: elseValue });
};
const handleAdd = () => {
const key = String(nextIndex);
setNextIndex(nextIndex + 1);
onChange({
...value,
match: { ...value.match, [key]: { if: '', then: '' } },
});
};
const handleRemove = (key: string) => {
const { [key]: _removed, ...rest } = value.match;
void _removed;
onChange({ ...value, match: rest });
};
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
{matchEntries.map(([key, entry], index) => (
<div key={key}>
<div className="rounded-md border bg-muted/30 p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.if', 'IF')}
</span>
<BufferedExprInput
value={entry.if}
onCommit={(v) => handleConditionChange(key, 'if', v)}
disabled={readOnly}
placeholder={t('expression.condition', 'Condition')}
className="flex-1"
/>
{!readOnly && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleRemove(key)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.then', 'THEN')}
</span>
<BufferedExprInput
value={entry.then}
onCommit={(v) => handleConditionChange(key, 'then', v)}
disabled={readOnly}
placeholder={t('expression.result', 'Result')}
className="flex-1"
/>
{!readOnly && <div className="w-8 shrink-0" />}
</div>
</div>
{index < matchEntries.length - 1 && <Separator className="my-3" />}
</div>
))}
{!readOnly && (
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAdd}
className="h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<Plus className="h-3 w-3" />
{t('expression.addCondition', 'Add condition')}
</Button>
</div>
)}
{matchEntries.length > 0 && <Separator />}
<div className="flex items-center gap-2">
{matchEntries.length > 0 && (
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground w-12 shrink-0">
{t('expression.else', 'ELSE')}
</span>
)}
<BufferedExprInput
value={value.else}
onCommit={handleElseChange}
disabled={readOnly}
placeholder={
matchEntries.length > 0 ? t('expression.defaultValue', 'Default value') : t('expression.value', 'Value')
}
className="flex-1"
/>
{!readOnly && matchEntries.length > 0 && <div className="w-8 shrink-0" />}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { createContext, useContext } from 'react';
import { useAccountStore } from '@/stores/accountStore';
export type EditionValue = 'enterprise' | 'community' | 'oss';
export const FormEditionContext = createContext<EditionValue | null>(null);
export function useEffectiveEdition(): EditionValue {
const override = useContext(FormEditionContext);
const storeEdition = useAccountStore((s) => s.edition);
return override ?? storeEdition;
}
+233
View File
@@ -0,0 +1,233 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as OTPAuth from 'otpauth';
import QRCode from 'qrcode';
import { Loader2, ShieldCheck, ShieldOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface OtpAuthValue {
otpUrl?: string | null;
otpCode?: string | null;
}
interface OtpAuthFieldProps {
value: unknown;
onChange: (value: unknown) => void;
readOnly: boolean;
}
const SECRET_MASK = '*****';
const STALWART_IMAGE_URL = 'https://stalw.art/img/favicon-32x32.png';
function buildOtpAuthUrl(totp: OTPAuth.TOTP): string {
const base = totp.toString();
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}image=${encodeURIComponent(STALWART_IMAGE_URL)}`;
}
function generateTotp(): { totp: OTPAuth.TOTP; url: string } {
const totp = new OTPAuth.TOTP({
issuer: 'Stalwart',
label: 'account',
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new OTPAuth.Secret({ size: 20 }),
});
return { totp, url: buildOtpAuthUrl(totp) };
}
export function OtpAuthField({ value, onChange, readOnly }: OtpAuthFieldProps) {
const { t } = useTranslation();
const objValue = (value as OtpAuthValue | null | undefined) ?? {};
const isConfigured = objValue.otpUrl != null && objValue.otpUrl !== '';
const [setupTotp, setSetupTotp] = useState<OTPAuth.TOTP | null>(null);
const [setupUrl, setSetupUrl] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [setupCode, setSetupCode] = useState('');
const [setupError, setSetupError] = useState<string | null>(null);
useEffect(() => {
if (!setupUrl) return;
let cancelled = false;
QRCode.toDataURL(setupUrl, { width: 220, margin: 1 })
.then((dataUrl) => {
if (!cancelled) setQrDataUrl(dataUrl);
})
.catch((err) => {
console.error('Failed to render QR code', err);
});
return () => {
cancelled = true;
};
}, [setupUrl]);
const startSetup = () => {
const { totp, url } = generateTotp();
setSetupTotp(totp);
setSetupUrl(url);
setSetupCode('');
setSetupError(null);
};
const confirmSetup = () => {
if (!setupTotp || !setupUrl) return;
if (!setupCode.trim()) {
setSetupError(t('otp.enterCodePrompt', 'Enter the code shown in your authenticator.'));
return;
}
const delta = setupTotp.validate({ token: setupCode.trim(), window: 1 });
if (delta === null) {
setSetupError(t('otp.codeIncorrect', 'That code is incorrect. Make sure your authenticator clock is in sync.'));
return;
}
setSetupError(null);
onChange({ otpUrl: setupUrl, otpCode: setupCode.trim() });
setSetupTotp(null);
setSetupUrl(null);
setSetupCode('');
};
const cancelSetup = () => {
setSetupTotp(null);
setSetupUrl(null);
setSetupCode('');
setSetupError(null);
};
const otpCodeValue = useMemo(
() => (typeof objValue.otpCode === 'string' && objValue.otpCode !== SECRET_MASK ? objValue.otpCode : ''),
[objValue.otpCode],
);
const handleCodeChange = (code: string) => {
onChange({ ...objValue, otpCode: code });
};
const handleDisable = () => {
onChange({ ...objValue, otpUrl: null });
};
if (readOnly) {
return (
<div className="text-sm text-muted-foreground">
{isConfigured
? t('otp.statusEnabled', 'Two-factor authentication is enabled.')
: t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
</div>
);
}
if (!isConfigured && setupUrl) {
return (
<div className="rounded-md border bg-muted/30 p-4 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">{t('otp.scanPrompt', 'Scan with your authenticator app')}</p>
<p className="text-xs text-muted-foreground">
{t(
'otp.scanDescription',
'Scan the QR code below with Google Authenticator, 1Password, Authy, or any other TOTP app, then enter the 6-digit code it shows to confirm setup.',
)}
</p>
</div>
<div className="flex justify-center">
{qrDataUrl ? (
<img
src={qrDataUrl}
alt={t('otp.qrCodeAlt', 'TOTP QR code')}
width={220}
height={220}
className="rounded-md border bg-white p-2"
/>
) : (
<div className="flex h-[220px] w-[220px] items-center justify-center text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.confirmationCodeLabel', 'Confirmation code')}</Label>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={setupCode}
onChange={(e) => setSetupCode(e.target.value.replace(/\s+/g, ''))}
maxLength={10}
className="font-mono tracking-widest text-center max-w-[12rem]"
/>
{setupError && <p className="text-xs text-destructive">{setupError}</p>}
</div>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelSetup}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" size="sm" onClick={confirmSetup}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('common.confirm', 'Confirm')}
</Button>
</div>
</div>
);
}
if (!isConfigured) {
return (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{t('otp.statusDisabled', 'Two-factor authentication is not enabled.')}
</span>
<Button type="button" variant="outline" size="sm" onClick={startSetup}>
<ShieldCheck className="mr-2 h-4 w-4" />
{t('otp.setUp', 'Set up TOTP')}
</Button>
</div>
);
}
return (
<div className="rounded-md border bg-muted/30 p-4 space-y-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-green-600" />
<span className="text-sm font-medium">{t('otp.statusEnabled', 'Two-factor authentication is enabled.')}</span>
</div>
<p className="text-xs text-muted-foreground">
{t(
'otp.currentCodePrompt',
'Enter your current 6-digit code to authorise any change to this account (including disabling two-factor authentication).',
)}
</p>
<div className="space-y-1.5">
<Label className="text-sm font-medium">{t('otp.currentCodeLabel', 'Current code')}</Label>
<Input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="123456"
value={otpCodeValue}
onChange={(e) => handleCodeChange(e.target.value.replace(/\s+/g, ''))}
maxLength={10}
className="font-mono tracking-widest text-center max-w-[12rem]"
/>
</div>
<div className="flex items-center justify-end">
<Button type="button" variant="destructive" size="sm" onClick={handleDisable} disabled={!otpCodeValue.trim()}>
<ShieldOff className="mr-2 h-4 w-4" />
{t('otp.disable', 'Disable')}
</Button>
</div>
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import i18n from '@/i18n';
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div role="alert" className="flex flex-col items-center justify-center gap-4 p-8">
<div className="text-destructive text-lg font-semibold">
{i18n.t('errorBoundary.title', 'Something went wrong')}
</div>
<p className="text-muted-foreground text-sm max-w-md text-center">
{this.state.error?.message ?? i18n.t('errors.unexpectedError', 'An unexpected error occurred.')}
</p>
<Button variant="outline" onClick={this.handleReset}>
{i18n.t('errorBoundary.tryAgain', 'Try Again')}
</Button>
</div>
);
}
return this.props.children;
}
}
+98
View File
@@ -0,0 +1,98 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect } from 'react';
import { useSchemaStore } from '@/stores/schemaStore';
import { useCacheStore } from '@/stores/cacheStore';
import { useAccountStore } from '@/stores/accountStore';
import { resolveObject } from '@/lib/schemaResolver';
import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { DashboardView } from '@/features/dashboard/components/DashboardView';
import { DeliveryTracePage } from '@/features/troubleshoot/DeliveryTracePage';
import { LiveTracingPage } from '@/features/tracing/components/LiveTracingPage';
import { TraceDetailView } from '@/features/tracing/components/TraceDetailView';
import { ActionPage } from '@/features/actions/ActionPage';
interface MainContentProps {
viewName?: string;
id?: string;
section?: string;
}
export function MainContent({ viewName, id, section }: MainContentProps) {
const schema = useSchemaStore((s) => s.schema);
const invalidateAllObjectLists = useCacheStore((s) => s.invalidateAllObjectLists);
useEffect(() => {
invalidateAllObjectLists();
}, [viewName, invalidateAllObjectLists]);
if (!viewName) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">Select a view from the sidebar.</div>
);
}
if (viewName.startsWith('Dashboard/')) {
const dashboardId = viewName.slice('Dashboard/'.length);
return <DashboardView dashboardId={dashboardId} section={section ?? ''} />;
}
if (viewName.startsWith('CustomComponent/')) {
const componentName = viewName.slice('CustomComponent/'.length);
if (componentName === 'Dashboard') {
const firstId = schema?.dashboards?.[0]?.id ?? 'overview';
return <DashboardView dashboardId={firstId} section={section ?? ''} />;
}
if (componentName === 'LiveDelivery') {
return <DeliveryTracePage />;
}
if (componentName === 'LiveTracing') {
return <LiveTracingPage />;
}
return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName}
</div>
);
}
if (!schema) {
return <div className="flex items-center justify-center p-8 text-muted-foreground">Loading...</div>;
}
const resolved = resolveObject(schema, viewName);
if (!resolved) {
return <div className="flex items-center justify-center p-8 text-destructive">Unknown view: {viewName}</div>;
}
if (resolved.objectName === 'x:Action') {
return <ActionPage viewName={viewName} />;
}
if (resolved.objectType.type === 'singleton') {
return <DynamicForm viewName={viewName} objectId="singleton" />;
}
if (id === 'new') {
return <DynamicForm viewName={viewName} objectId={null} />;
}
if (id) {
if (resolved.objectName === 'x:Trace' && id !== 'new') {
return <TraceDetailView viewName={viewName} objectId={id} />;
}
const canUpdate = useAccountStore.getState().hasObjectPermission(resolved.permissionPrefix, 'Update');
if (!canUpdate) {
return <DynamicViewPage viewName={viewName} objectId={id} />;
}
return <DynamicForm viewName={viewName} objectId={id} />;
}
return <DynamicList viewName={viewName} />;
}
+19
View File
@@ -0,0 +1,19 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/stores/authStore';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const accessToken = useAuthStore((s) => s.accessToken);
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
const location = useLocation();
if (!accessToken && !bypassToken) {
const originalPath = location.pathname + location.search;
return <Navigate to="/login" replace state={{ from: originalPath }} />;
}
return <>{children}</>;
}
+354
View File
@@ -0,0 +1,354 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons;
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useUIStore } from '@/stores/uiStore';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
import {
visibleLayouts,
findFirstVisibleLinkInLayout,
findFirstAccessibleLinkInLayout,
isLinkEnterprise,
isLinkVisible,
} from '@/lib/layout';
import type { Layout, LayoutItem, LayoutSubItem } from '@/types/schema';
function LucideIcon({ name, className }: { name: string; className?: string }) {
const formatted = name
.split('-')
.map((s) => s[0].toUpperCase() + s.slice(1))
.join('');
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
if (!IconComp) return <LucideIcons.Circle className={className} />;
return <IconComp className={className} />;
}
function resolveViewPath(sectionName: string, viewName: string): string {
return `/${sectionName}/${viewName}`;
}
function pathMatchesView(currentPath: string, sectionName: string, viewName: string): boolean {
const base = `/${sectionName}/${viewName}`;
if (currentPath === base || currentPath.startsWith(`${base}/`)) return true;
if (viewName === 'CustomComponent/Dashboard') {
const dashBase = `/${sectionName}/Dashboard/`;
return currentPath.startsWith(dashBase);
}
return false;
}
function subtreeContainsActive(items: LayoutSubItem[], currentPath: string, sectionName: string): boolean {
for (const item of items) {
if (item.type === 'link') {
if (pathMatchesView(currentPath, sectionName, item.viewName)) return true;
} else if (item.type === 'container') {
if (subtreeContainsActive(item.items, currentPath, sectionName)) return true;
}
}
return false;
}
function subtreeHasVisibleLink(items: LayoutSubItem[], edition: string): boolean {
for (const item of items) {
if (item.type === 'link') {
if (!checkLinkVisible(item.viewName)) continue;
const enterprise = checkIsEnterprise(item.viewName);
if (enterprise && edition === 'oss') continue;
return true;
} else if (item.type === 'container') {
if (subtreeHasVisibleLink(item.items, edition)) return true;
}
}
return false;
}
function checkLinkVisible(viewName: string): boolean {
const schema = useSchemaStore.getState().schema;
if (!schema) return true;
const accountStore = useAccountStore.getState();
return isLinkVisible(
schema,
viewName,
accountStore.edition,
(prefix: string) => accountStore.hasObjectPermission(prefix, 'Get'),
(perm: string) => accountStore.hasPermission(perm),
);
}
function checkIsEnterprise(viewName: string): boolean {
const schema = useSchemaStore.getState().schema;
if (!schema) return false;
const edition = useAccountStore.getState().edition;
return isLinkEnterprise(schema, viewName, edition);
}
interface SidebarSubItemProps {
item: LayoutSubItem;
depth: number;
sectionName: string;
currentPath: string;
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
}
function SidebarSubItem({ item, depth, sectionName, currentPath, navigate, edition, onUpsell }: SidebarSubItemProps) {
if (item.type === 'link') {
if (!checkLinkVisible(item.viewName)) return null;
const path = resolveViewPath(sectionName, item.viewName);
const isActive = pathMatchesView(currentPath, sectionName, item.viewName);
const enterprise = checkIsEnterprise(item.viewName);
const isLocked = enterprise && edition === 'community';
const isHidden = enterprise && edition === 'oss';
if (isHidden) return null;
return (
<Button
variant="ghost"
className={cn(
'w-full justify-start gap-2 font-normal',
isActive && 'bg-accent text-accent-foreground',
depth > 0 && 'text-sm',
)}
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
onClick={() => {
if (isLocked) {
onUpsell();
} else {
navigate(path);
}
}}
>
<span className="truncate">{item.name || 'Overview'}</span>
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
</Button>
);
}
if (item.type === 'container') {
if (!subtreeHasVisibleLink(item.items, edition)) return null;
const containsActive = subtreeContainsActive(item.items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-start gap-2 font-normal text-sm"
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
>
<ChevronDown className="h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
<span className="truncate">{item.name}</span>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{item.items.map((sub) => (
<SidebarSubItem
key={sub.type === 'link' ? sub.viewName : sub.name}
item={sub}
depth={depth + 1}
sectionName={sectionName}
currentPath={currentPath}
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
return null;
}
interface SidebarTopItemProps {
item: LayoutItem;
sectionName: string;
currentPath: string;
navigate: ReturnType<typeof useNavigate>;
edition: string;
onUpsell: () => void;
}
function SidebarTopItem({ item, sectionName, currentPath, navigate, edition, onUpsell }: SidebarTopItemProps) {
if ('link' in item) {
const { name, icon, viewName } = item.link;
if (!checkLinkVisible(viewName)) return null;
const path = resolveViewPath(sectionName, viewName);
const isActive = pathMatchesView(currentPath, sectionName, viewName);
const enterprise = checkIsEnterprise(viewName);
const isLocked = enterprise && edition === 'community';
const isHidden = enterprise && edition === 'oss';
if (isHidden) return null;
return (
<Button
variant="ghost"
className={cn('w-full justify-start gap-2 font-normal', isActive && 'bg-accent text-accent-foreground')}
onClick={() => {
if (isLocked) {
onUpsell();
} else {
navigate(path);
}
}}
>
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{name}</span>
{isLocked && <Lock className="ml-auto h-3 w-3 text-muted-foreground" />}
</Button>
);
}
if ('container' in item) {
const { name, icon, items } = item.container;
if (!subtreeHasVisibleLink(items, edition)) return null;
const containsActive = subtreeContainsActive(items, currentPath, sectionName);
return (
<Collapsible defaultOpen={containsActive}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-start gap-2 font-normal">
<LucideIcon name={icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{name}</span>
<ChevronDown className="ml-auto h-3 w-3 shrink-0 transition-transform duration-200 [[data-state=closed]>&]:rotate-[-90deg]" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
{items.map((sub) => (
<SidebarSubItem
key={sub.type === 'link' ? sub.viewName : sub.name}
item={sub}
depth={1}
sectionName={sectionName}
currentPath={currentPath}
navigate={navigate}
edition={edition}
onUpsell={onUpsell}
/>
))}
</CollapsibleContent>
</Collapsible>
);
}
return null;
}
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const activeSection = useUIStore((s) => s.activeSection);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const schema = useSchemaStore((s) => s.schema);
const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission);
const [upsellOpen, setUpsellOpen] = useState(false);
const layouts = useMemo(
() =>
schema ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) : [],
[schema, edition, hasObjectPermission, hasPermission],
);
useEffect(() => {
if (!schema) return;
if (layouts.length === 0) return;
if (!layouts.find((l) => l.name === activeSection)) {
setActiveSection(layouts[0].name);
}
}, [schema, layouts, activeSection, setActiveSection]);
if (!sidebarOpen || !schema) return null;
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
if (!layout) return null;
const handleSectionClick = (target: Layout) => {
setActiveSection(target.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const first =
findFirstAccessibleLinkInLayout(schema, target, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, target, edition, canGet, hasPermission);
if (first) navigate(`/${target.name}/${first}`);
};
return (
<aside className="fixed top-14 left-0 bottom-0 z-30 hidden w-64 flex-col border-r bg-background md:flex">
<ScrollArea className="flex-1 py-2">
<nav className="flex flex-col gap-0.5 px-2">
{layout.items.map((item) => (
<SidebarTopItem
key={'link' in item ? item.link.viewName : item.container.name}
item={item}
sectionName={layout.name}
currentPath={location.pathname}
navigate={navigate}
edition={edition}
onUpsell={() => setUpsellOpen(true)}
/>
))}
</nav>
</ScrollArea>
{layouts.length > 1 && (
<TooltipProvider>
<div className="flex items-center justify-around border-t bg-background px-2 py-2">
{layouts.map((target) => {
const Icon = (LucideIcons as Record<string, unknown>)[
target.icon
.split('-')
.map((s) => s[0].toUpperCase() + s.slice(1))
.join('')
] as LucideIcons.LucideIcon | undefined;
const isActive = target.name === activeSection;
return (
<Tooltip key={target.name}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={target.name}
aria-current={isActive ? 'page' : undefined}
onClick={() => handleSectionClick(target)}
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
>
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent side="top">{target.name}</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
)}
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
</aside>
);
}
+155
View File
@@ -0,0 +1,155 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles } = LucideIcons;
import { Button } from '@/components/ui/button';
import { GlobalSearch } from '@/components/common/GlobalSearch';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import Logo from '@/components/common/Logo';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
import { useUIStore } from '@/stores/uiStore';
import { useAuthStore } from '@/stores/authStore';
import { useState } from 'react';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
function getIcon(name: string): LucideIcons.LucideIcon {
const formatted = name
.split('-')
.map((s) => s[0].toUpperCase() + s.slice(1))
.join('');
return ((LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon) || LucideIcons.Circle;
}
export function TopBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useUIStore((s) => s.theme);
const toggleTheme = useUIStore((s) => s.toggleTheme);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const accounts = useAuthStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const logout = useAuthStore((s) => s.logout);
const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission);
const schema = useSchemaStore((s) => s.schema);
const [upsellOpen, setUpsellOpen] = useState(false);
const navigableLayouts = schema
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
: [];
return (
<header className="sticky top-0 z-40 flex h-14 items-center gap-4 border-b bg-background px-4">
<Button variant="ghost" size="icon" onClick={toggleSidebar} className="md:hidden">
<Menu className="h-4 w-4" />
</Button>
<Link to="/" className="flex shrink-0 items-center">
<Logo />
</Link>
<GlobalSearch />
<div className="flex items-center gap-2">
{edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />}
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('userMenu', 'User menu')}>
<User className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{schema && navigableLayouts.length > 0 && (
<>
<DropdownMenuLabel>{t('sections', 'Sections')}</DropdownMenuLabel>
<DropdownMenuGroup>
{navigableLayouts.map((layout) => {
const Icon = getIcon(layout.icon);
return (
<DropdownMenuItem
key={layout.name}
onClick={() => {
setActiveSection(layout.name);
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const firstLink =
findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPermission) ??
findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPermission);
if (firstLink) {
navigate(`/${layout.name}/${firstLink}`);
}
}}
>
<Icon className="mr-2 h-4 w-4" />
{layout.name}
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
{Object.keys(accounts).length > 0 && (
<>
<DropdownMenuLabel>{t('accounts', 'Accounts')}</DropdownMenuLabel>
<DropdownMenuGroup>
{Object.entries(accounts).map(([id, info]) => (
<DropdownMenuItem key={id} onClick={() => switchAccount(id)}>
{id === activeAccountId && <Check className="mr-2 h-4 w-4" />}
<span className={id !== activeAccountId ? 'ml-6' : ''}>{info.name || id}</span>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
</>
)}
{edition !== 'enterprise' && (
<>
<DropdownMenuItem onClick={() => setUpsellOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
{t('tryEnterprise', 'Try Enterprise')}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={() => {
logout();
navigate('/login');
}}
>
<LogOut className="mr-2 h-4 w-4" />
{t('logout', 'Logout')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
import { cn } from '@/lib/utils';
import { buttonVariants } from '@/components/ui/button';
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
);
AlertDialogHeader.displayName = 'AlertDialogHeader';
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
AlertDialogFooter.displayName = 'AlertDialogFooter';
const AlertDialogTitle = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ComponentRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};
+35
View File
@@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+52
View File
@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = 'Button';
export { Button, buttonVariants };
+49
View File
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
),
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
),
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
),
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
),
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+60
View File
@@ -0,0 +1,60 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { Tooltip as RechartsTooltip } from 'recharts';
import type { TooltipPayload } from 'recharts/types/state/tooltipSlice';
import { cn } from '@/lib/utils';
export const CHART_COLORS = [
'hsl(var(--chart-1))',
'hsl(var(--chart-2))',
'hsl(var(--chart-3))',
'hsl(var(--chart-4))',
'hsl(var(--chart-5))',
] as const;
export function getChartColor(index: number): string {
return CHART_COLORS[index % CHART_COLORS.length];
}
export function ChartContainer({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn('w-full', className)}>{children}</div>;
}
export function ChartTooltipContent({
active,
payload,
label,
formatter,
}: {
active?: boolean;
payload?: TooltipPayload;
label?: string;
formatter?: (value: number) => string;
}) {
if (!active || !payload?.length) return null;
return (
<div className="rounded-lg border bg-background p-2 shadow-md">
{label && <p className="mb-1 text-xs text-muted-foreground">{label}</p>}
{payload.map((entry, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: entry.color ?? 'currentColor' }}
/>
<span className="text-muted-foreground">{entry.name ?? ''}</span>
<span className="ml-auto font-medium">
{formatter && typeof entry.value === 'number' ? formatter(entry.value) : String(entry.value ?? '')}
</span>
</div>
))}
</div>
);
}
export { RechartsTooltip };
+32
View File
@@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+15
View File
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
+142
View File
@@ -0,0 +1,142 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { ChevronsUpDown, Check, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
export interface ComboboxOption {
value: string;
label: string;
description?: string;
marker?: ReactNode;
keywords?: string[];
}
interface ComboboxProps {
options: ComboboxOption[];
value: string;
onValueChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
contentClassName?: string;
nullable?: boolean;
nullLabel?: string;
onFirstOpen?: () => void;
className?: string;
}
export function Combobox({
options,
value,
onValueChange,
placeholder = 'Select...',
searchPlaceholder = 'Search...',
emptyText = 'No matches.',
disabled,
contentClassName,
nullable,
nullLabel = 'None',
onFirstOpen,
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const [triggerWidth, setTriggerWidth] = useState<number | undefined>(undefined);
const firstOpenRef = useRef(false);
useEffect(() => {
if (open && triggerRef.current) {
setTriggerWidth(triggerRef.current.offsetWidth);
}
}, [open]);
const handleOpenChange = (next: boolean) => {
if (next && !firstOpenRef.current) {
firstOpenRef.current = true;
onFirstOpen?.();
}
setOpen(next);
};
const selected = options.find((o) => o.value === value);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn('w-full justify-between font-normal', !selected && 'text-muted-foreground', className)}
>
<span className="flex items-center gap-2 truncate">
{selected?.marker}
{selected?.label ?? placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className={cn('p-0', contentClassName)}
align="start"
style={triggerWidth ? { width: triggerWidth } : undefined}
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{nullable && (
<CommandItem
value="__null__"
onSelect={() => {
onValueChange('');
setOpen(false);
}}
>
<X className="mr-2 h-4 w-4 opacity-50" />
<span className="text-muted-foreground">{nullLabel}</span>
</CommandItem>
)}
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label, ...(option.keywords ?? [])]}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<Check className={cn('mr-2 h-4 w-4', option.value === value ? 'opacity-100' : 'opacity-0')} />
<span className="flex flex-1 items-center gap-2 truncate">
{option.marker}
<span className="flex flex-col truncate">
<span className="truncate">{option.label}</span>
{option.description && (
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
)}
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+131
View File
@@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { type DialogProps } from '@radix-ui/react-dialog';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command 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">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 h-px bg-border', className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)} {...props} />;
};
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
+101
View File
@@ -0,0 +1,101 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
);
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
);
DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+186
View File
@@ -0,0 +1,186 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
inset && 'pl-8',
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />;
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};
+28
View File
@@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = 'Input';
export { Input };
+23
View File
@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const labelVariants = cva('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70');
const Label = React.forwardRef<
React.ComponentRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+37
View File
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as PopoverPrimitive from '@radix-ui/react-popover';
import { cn } from '@/lib/utils';
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+44
View File
@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import { cn } from '@/lib/utils';
const ScrollArea = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root ref={ref} className={cn('relative overflow-hidden', className)} {...props}>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">{children}</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = 'vertical', ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-[1px]',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-[1px]',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
+148
View File
@@ -0,0 +1,148 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label ref={ref} className={cn('px-2 py-1.5 text-sm font-semibold', className)} {...props} />
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ComponentRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-muted', className)} {...props} />
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
+26
View File
@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ComponentRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = 'horizontal', decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', className)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+33
View File
@@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils';
const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+59
View File
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as TabsPrimitive from '@radix-ui/react-tabs';
import { cn } from '@/lib/utils';
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
className,
)}
{...props}
/>
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ComponentRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent };
+27
View File
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };
+94
View File
@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
const ToastViewport = React.forwardRef<HTMLOListElement, React.HTMLAttributes<HTMLOListElement>>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
),
);
ToastViewport.displayName = 'ToastViewport';
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
success: 'border-green-500 bg-background text-foreground',
warning: 'border-yellow-500 bg-background text-foreground',
info: 'border-blue-500 bg-background text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Toast = React.forwardRef<
HTMLLIElement,
React.HTMLAttributes<HTMLLIElement> &
VariantProps<typeof toastVariants> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
>(({ className, variant, open, onOpenChange, ...props }, ref) => (
<li
ref={ref}
className={cn(toastVariants({ variant }), className)}
data-state={open ? 'open' : 'closed'}
{...props}
/>
));
Toast.displayName = 'Toast';
const ToastClose = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement>>(
({ className, ...props }, ref) => (
<button
ref={ref}
aria-label="Close"
className={cn(
'absolute right-1 top-1 rounded-md p-1 text-foreground/60 transition-colors hover:text-foreground focus:outline-none focus:ring-1 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
type="button"
{...props}
>
<X className="h-4 w-4" />
</button>
),
);
ToastClose.displayName = 'ToastClose';
const ToastTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm font-semibold [&+div]:text-xs', className)} {...props} />
),
);
ToastTitle.displayName = 'ToastTitle';
const ToastDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('text-sm opacity-90', className)} {...props} />,
);
ToastDescription.displayName = 'ToastDescription';
export { ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, toastVariants };
export type { VariantProps };
+30
View File
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Toast, ToastClose, ToastDescription, ToastTitle, ToastViewport } from '@/components/ui/toast';
import { useToast } from '@/hooks/use-toast';
export function Toaster() {
const { toasts } = useToast();
const visibleToasts = toasts.filter((t) => t.open !== false);
return (
<ToastViewport>
{visibleToasts.map(function ({ id, title, description, variant, open, onOpenChange, ...props }) {
return (
<Toast key={id} variant={variant} open={open} onOpenChange={onOpenChange} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
<ToastClose onClick={() => onOpenChange?.(false)} />
</Toast>
);
})}
</ToastViewport>
);
}
+36
View File
@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
</TooltipPrimitive.Portal>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+543
View File
@@ -0,0 +1,543 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import { Check, X, HelpCircle, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { jmapMapToArray } from '@/lib/jmapUtils';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { resolveSchema, resolveVariantForm, resolveForm } from '@/lib/schemaResolver';
import { formatSize, formatDuration } from '@/lib/durationFormat';
import type { Schema, Field, FieldType, FormField, Form, Fields, EnumVariant } from '@/types/schema';
export interface DynamicViewProps {
schema: Schema;
objectName: string;
viewName: string;
data: Record<string, unknown>;
visibleFields?: Set<string>;
}
export function DynamicView({ schema, objectName, viewName, data, visibleFields }: DynamicViewProps) {
const resolved = useMemo(() => {
const sch = resolveSchema(schema, objectName);
if (!sch) return null;
let fields: Fields | null = null;
let form: Form | null = null;
let variantSchemaName: string | undefined;
if (sch.type === 'single') {
fields = sch.fields;
form = resolveForm(schema, viewName, objectName, sch.schemaName);
} else {
const variantName = typeof data['@type'] === 'string' ? data['@type'] : undefined;
if (variantName) {
const variant = sch.variants.find((v) => v.name === variantName);
if (variant?.schemaName) {
variantSchemaName = variant.schemaName;
fields = schema.fields[variant.schemaName] ?? null;
form = resolveVariantForm(schema, viewName, objectName, variant.schemaName);
}
}
const parentForm = resolveForm(schema, viewName, objectName, objectName);
if (parentForm && form) {
form = { ...form, sections: [...parentForm.sections, ...form.sections] };
} else if (parentForm) {
form = parentForm;
}
}
return { sch, fields, form, variantSchemaName };
}, [schema, objectName, viewName, data]);
if (!resolved) return null;
const { fields, form, sch } = resolved;
const sections: { title?: string; items: { ff: FormField; field: Field }[] }[] = [];
if (form) {
for (const section of form.sections) {
const items: { ff: FormField; field: Field }[] = [];
for (const ff of section.fields) {
if (ff.name === '@type') continue;
if (visibleFields && !visibleFields.has(ff.name)) continue;
const field = fields?.properties[ff.name];
if (!field) continue;
if (isEmptyValue(data[ff.name])) continue;
items.push({ ff, field });
}
if (items.length > 0) {
sections.push({ title: section.title, items });
}
}
} else if (fields) {
const items: { ff: FormField; field: Field }[] = [];
for (const [name, field] of Object.entries(fields.properties)) {
if (visibleFields && !visibleFields.has(name)) continue;
if (isEmptyValue(data[name])) continue;
items.push({ ff: { name, label: name }, field });
}
if (items.length > 0) {
sections.push({ items });
}
}
const variantLabel =
sch.type === 'multiple' && typeof data['@type'] === 'string'
? sch.variants.find((v) => v.name === data['@type'])?.label
: undefined;
return (
<div className="space-y-4">
{variantLabel && (
<Badge variant="secondary" className="text-xs">
{variantLabel}
</Badge>
)}
{sections.map((section, si) => (
<Card key={si}>
{section.title && (
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
</CardHeader>
)}
<CardContent className={section.title ? 'pt-0' : ''}>
<dl className="space-y-2">
{section.items.map(({ ff, field }) => (
<ViewField key={ff.name} label={ff.label} field={field} value={data[ff.name]} schema={schema} />
))}
</dl>
</CardContent>
</Card>
))}
</div>
);
}
function ViewField({ label, field, value, schema }: { label: string; field: Field; value: unknown; schema: Schema }) {
const isBlock = isBlockType(field.type, value);
return (
<div className={isBlock ? 'space-y-1' : 'flex items-baseline gap-2'}>
<dt className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[140px]">
{label}
{field.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/50 cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs prose prose-sm dark:prose-invert">
<ReactMarkdown>{field.description.replace(/\\n/g, '\n')}</ReactMarkdown>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</dt>
<dd className="text-sm min-w-0">
<ViewValue type={field.type} value={value} schema={schema} />
</dd>
</div>
);
}
function isBlockType(type: FieldType, value: unknown): boolean {
if (type.type === 'object' || type.type === 'objectList') return true;
if (type.type === 'map') return true;
if (type.type === 'string' && (type.format === 'text' || type.format === 'html')) return true;
if (type.type === 'set' && value && typeof value === 'object') {
return Object.keys(value as Record<string, unknown>).length > 5;
}
return false;
}
function ViewValue({ type, value, schema }: { type: FieldType; value: unknown; schema: Schema }) {
if (value === null || value === undefined) {
return <span className="italic text-muted-foreground">-</span>;
}
switch (type.type) {
case 'string':
return <StringValue value={value} format={type.format} />;
case 'number':
return <NumberValue value={value} format={type.format} />;
case 'utcDateTime':
return <DateTimeValue value={value} />;
case 'boolean':
return value === true ? <Check className="h-4 w-4 text-green-600" /> : <X className="h-4 w-4 text-red-500" />;
case 'enum':
return <EnumValue value={value} enumName={type.enumName} schema={schema} />;
case 'blobId':
return <span className="font-mono text-xs">{String(value)}</span>;
case 'objectId':
return <span>{String(value)}</span>;
case 'object':
return <ObjectValue value={value} objectName={type.objectName} schema={schema} />;
case 'objectList':
return <ObjectListValue value={value} objectName={type.objectName} schema={schema} />;
case 'set':
return <SetValue value={value} classType={type.class} schema={schema} />;
case 'map':
return <MapValue value={value} keyClass={type.keyClass} valueClass={type.valueClass} schema={schema} />;
default:
return <span>{JSON.stringify(value)}</span>;
}
}
function StringValue({ value, format }: { value: unknown; format: string }) {
const str = String(value);
if (!str) return <span className="italic text-muted-foreground">-</span>;
if (format === 'text' || format === 'html' || str.includes('\n')) {
return <pre className="whitespace-pre-wrap break-all rounded bg-muted/50 p-2 text-xs font-mono">{str}</pre>;
}
if (format === 'secret' || format === 'secretText') {
return <span className="text-muted-foreground">*****</span>;
}
return <span className="break-all">{str}</span>;
}
function NumberValue({ value, format }: { value: unknown; format: string }) {
const num = typeof value === 'number' ? value : Number(value);
if (isNaN(num)) return <span>{String(value)}</span>;
switch (format) {
case 'size':
return <span>{formatSize(num)}</span>;
case 'duration':
return <span>{formatDuration(num)}</span>;
default:
return <span>{num.toLocaleString()}</span>;
}
}
function DateTimeValue({ value }: { value: unknown }) {
if (!value) return <span className="italic text-muted-foreground">-</span>;
const str = String(value);
const d = new Date(str);
const formatted = !isNaN(d.getTime())
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(d)
: str;
return <span>{formatted}</span>;
}
function EnumValue({ value, enumName, schema }: { value: unknown; enumName: string; schema: Schema }) {
const str = String(value);
const variants = schema.enums[enumName] ?? [];
const variant = variants.find((v: EnumVariant) => v.name === str);
if (variant) {
return (
<Badge variant="secondary" style={variant.color ? { backgroundColor: variant.color, color: '#fff' } : undefined}>
{variant.label}
</Badge>
);
}
return <span>{str}</span>;
}
function ObjectValue({
value,
objectName,
schema,
defaultOpen = false,
}: {
value: unknown;
objectName: string;
schema: Schema;
defaultOpen?: boolean;
}) {
if (!value || typeof value !== 'object') {
return <span className="italic text-muted-foreground">-</span>;
}
const data = value as Record<string, unknown>;
const sch = resolveSchema(schema, objectName);
let fields: Record<string, Field> | undefined;
let form: Form | null = null;
let variantLabel: string | undefined;
if (sch?.type === 'multiple') {
const variantName = typeof data['@type'] === 'string' ? data['@type'] : undefined;
if (variantName) {
const variant = sch.variants.find((v) => v.name === variantName);
variantLabel = variant?.label;
if (variant?.schemaName) {
fields = schema.fields[variant.schemaName]?.properties;
form = schema.forms[variant.schemaName] ?? null;
}
}
} else if (sch?.type === 'single') {
fields = schema.fields[sch.schemaName]?.properties;
form = schema.forms[sch.schemaName] ?? schema.forms[objectName] ?? null;
}
const entries = buildViewEntries(data, fields, form);
if (entries.length === 0 && !variantLabel) {
return null;
}
const triggerLabel = variantLabel ?? findDisplayValue(data) ?? objectName.replace(/^x:/, '');
return (
<Collapsible defaultOpen={defaultOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-sm hover:text-foreground text-muted-foreground transition-colors [&[data-state=open]>svg]:rotate-90"
>
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform duration-200" />
<span>{triggerLabel}</span>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-5 mt-2 border-l-2 border-muted/40 pl-4 space-y-2">
{entries.map(({ key, label, field, value: val }) => {
if (!field) {
return (
<div key={key} className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground shrink-0 min-w-[120px]">{label}</span>
<span className="text-sm">{typeof val === 'object' ? JSON.stringify(val) : String(val ?? '-')}</span>
</div>
);
}
const block = isBlockType(field.type, val);
return (
<div key={key} className={block ? 'space-y-1' : 'flex items-baseline gap-2'}>
<span className="flex items-center gap-1 text-sm text-muted-foreground shrink-0 min-w-[120px]">
{label}
{field.description && <FieldTooltip description={field.description} />}
</span>
<span className="text-sm min-w-0">
<ViewValue type={field.type} value={val} schema={schema} />
</span>
</div>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function ObjectListValue({ value, objectName, schema }: { value: unknown; objectName: string; schema: Schema }) {
const items = jmapMapToArray<Record<string, unknown>>(value);
if (items.length === 0) {
return <span className="italic text-muted-foreground">-</span>;
}
return (
<div className="space-y-2">
{items.map((item, i) => (
<ObjectValue key={i} value={item} objectName={objectName} schema={schema} />
))}
</div>
);
}
function SetValue({
value,
classType,
schema,
}: {
value: unknown;
classType: { type: string; enumName?: string };
schema: Schema;
}) {
if (!value || typeof value !== 'object') {
return <span className="italic text-muted-foreground">-</span>;
}
const keys = Object.keys(value as Record<string, unknown>);
if (keys.length === 0) {
return <span className="italic text-muted-foreground">-</span>;
}
const labels = keys.map((k) => {
if (classType.type === 'enum' && classType.enumName) {
const variants = schema.enums[classType.enumName] ?? [];
const v = variants.find((e: EnumVariant) => e.name === k);
return v?.label ?? k;
}
return k;
});
return (
<div className="flex flex-wrap gap-1">
{labels.map((label, i) => (
<Badge key={i} variant="secondary" className="text-xs">
{label}
</Badge>
))}
</div>
);
}
function MapValue({
value,
keyClass,
valueClass,
schema,
}: {
value: unknown;
keyClass: { type: string; enumName?: string };
valueClass: { type: string; objectName?: string };
schema: Schema;
}) {
if (!value || typeof value !== 'object') {
return <span className="italic text-muted-foreground">-</span>;
}
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) {
return <span className="italic text-muted-foreground">-</span>;
}
return (
<div className="space-y-2">
{entries.map(([k, v]) => {
let keyLabel = k;
if (keyClass.type === 'enum' && keyClass.enumName) {
const variants = schema.enums[keyClass.enumName] ?? [];
const variant = variants.find((e: EnumVariant) => e.name === k);
if (variant) keyLabel = variant.label;
}
if (valueClass.type === 'object' && valueClass.objectName) {
return (
<div key={k}>
<span className="text-sm font-medium">{keyLabel}</span>
<div className="ml-2">
<ObjectValue value={v} objectName={valueClass.objectName} schema={schema} />
</div>
</div>
);
}
return (
<div key={k} className="flex items-baseline gap-2">
<span className="text-sm text-muted-foreground shrink-0 min-w-[120px]">{keyLabel}</span>
<span className="text-sm">{typeof v === 'object' ? JSON.stringify(v) : String(v ?? '-')}</span>
</div>
);
})}
</div>
);
}
function FieldTooltip({ description }: { description: string }) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="h-3.5 w-3.5 text-muted-foreground/50 cursor-help shrink-0" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs prose prose-sm dark:prose-invert">
<ReactMarkdown>{description.replace(/\\n/g, '\n')}</ReactMarkdown>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
interface ViewEntry {
key: string;
label: string;
field: Field | undefined;
value: unknown;
}
function buildViewEntries(
data: Record<string, unknown>,
fields?: Record<string, Field>,
form?: Form | null,
): ViewEntry[] {
const entries: ViewEntry[] = [];
const seen = new Set<string>();
if (form) {
for (const section of form.sections) {
for (const ff of section.fields) {
if (ff.name === '@type') continue;
if (isEmptyValue(data[ff.name])) continue;
seen.add(ff.name);
entries.push({
key: ff.name,
label: ff.label,
field: fields?.[ff.name],
value: data[ff.name],
});
}
}
}
for (const [key, val] of Object.entries(data)) {
if (key === '@type' || key === 'id' || seen.has(key)) continue;
if (isEmptyValue(val)) continue;
entries.push({
key,
label: key,
field: fields?.[key],
value: val,
});
}
return entries;
}
function isEmptyValue(value: unknown): boolean {
if (value === null || value === undefined) return true;
if (value === '') return true;
if (Array.isArray(value) && value.length === 0) return true;
if (typeof value === 'object' && value !== null) {
return Object.keys(value as Record<string, unknown>).length === 0;
}
return false;
}
function findDisplayValue(data: Record<string, unknown>): string | null {
for (const key of [
'name',
'domain',
'headerFrom',
'envelopeFrom',
'sourceIp',
'orgName',
'organizationName',
'email',
'from',
'to',
'subject',
'policyDomain',
]) {
const val = data[key];
if (typeof val === 'string' && val.length > 0) return val;
}
for (const val of Object.values(data)) {
if (typeof val === 'string' && val.length > 0 && val.length <= 80) return val;
}
return null;
}
+111
View File
@@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useSchemaStore } from '@/stores/schemaStore';
import { resolveObject, resolveList } from '@/lib/schemaResolver';
import { jmapGet, getAccountId } from '@/services/jmap/client';
import { DynamicView } from './DynamicView';
interface DynamicViewPageProps {
viewName: string;
objectId: string;
}
export function DynamicViewPage({ viewName, objectId }: DynamicViewPageProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const schema = useSchemaStore((s) => s.schema);
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!schema) return;
let cancelled = false;
async function load() {
try {
const resolved = resolveObject(schema!, viewName);
if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object'));
const accountId = getAccountId(resolved.objectName);
const responses = await jmapGet(resolved.objectName, accountId, [objectId]);
if (cancelled) return;
const getResult = responses.find(([name]) => name.endsWith('/get'));
if (!getResult) throw new Error(t('view.noGetResponse', 'No get response'));
const result = getResult[1] as { list?: Array<Record<string, unknown>> };
const item = result.list?.[0];
if (!item) throw new Error(t('view.objectNotFound', 'Object not found'));
setData(item);
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : t('view.failedToLoad', 'Failed to load'));
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [schema, viewName, objectId, t]);
if (loading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data || !schema) {
return (
<div className="space-y-4">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
<div className="text-destructive p-4">{error ?? t('view.failedToLoad', 'Failed to load')}</div>
</div>
);
}
const resolved = resolveObject(schema, viewName);
if (!resolved) return null;
const list = resolveList(schema, viewName, resolved.objectName);
const labelProp = list?.labelProperty ?? list?.columns?.[0]?.name;
const displayName = labelProp && typeof data[labelProp] === 'string' ? (data[labelProp] as string) : undefined;
const singularName = list?.singularName ?? resolved.objectName.replace(/^x:/, '');
const title = displayName
? `${singularName.charAt(0).toUpperCase() + singularName.slice(1)}: ${displayName}`
: singularName.charAt(0).toUpperCase() + singularName.slice(1);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
<h1 className="text-xl font-semibold">{title}</h1>
</div>
<DynamicView schema={schema} objectName={resolved.objectName} viewName={viewName} data={data} />
</div>
);
}
+483
View File
@@ -0,0 +1,483 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Zap, Loader2, ChevronRight, CircleCheck, CircleAlert } from 'lucide-react';
import i18n from '@/i18n';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useSchemaStore } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { resolveObject, resolveSchema, resolveVariantForm } from '@/lib/schemaResolver';
import { jmapSet, getAccountId } from '@/services/jmap/client';
import { FieldWidget } from '@/components/forms/FieldWidget';
import { DynamicView } from '@/components/views/DynamicView';
import type { Schema, ObjectVariant } from '@/types/schema';
import type { JmapSetResponse } from '@/types/jmap';
type ActionState =
| { kind: 'pick' }
| { kind: 'form'; variant: ObjectVariant; formData: Record<string, unknown> }
| { kind: 'submitting'; variant: ObjectVariant }
| {
kind: 'result';
variant: ObjectVariant;
props: Record<string, unknown> | null;
inputData: Record<string, unknown>;
}
| { kind: 'error'; variant: ObjectVariant; error: string };
interface ActionPageProps {
viewName: string;
}
export function ActionPage({ viewName }: ActionPageProps) {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const hasPermission = useAccountStore((s) => s.hasPermission);
const [state, setState] = useState<ActionState>({ kind: 'pick' });
const resolved = useMemo(() => {
if (!schema) return null;
return resolveObject(schema, viewName);
}, [schema, viewName]);
const sch = useMemo(() => {
if (!schema || !resolved) return null;
return resolveSchema(schema, resolved.objectName);
}, [schema, resolved]);
const canCreate = resolved ? hasObjectPermission(resolved.permissionPrefix, 'Create') : false;
const groups = useMemo(() => {
const variants = sch?.type === 'multiple' ? sch.variants : [];
const map = new Map<string, ObjectVariant[]>();
const otherLabel = t('actions.otherGroup', 'Other');
for (const v of variants) {
if (!hasPermission(`action${v.name}`)) continue;
const colonIdx = v.label.indexOf(':');
const group = colonIdx > 0 ? v.label.slice(0, colonIdx).trim() : otherLabel;
let list = map.get(group);
if (!list) {
list = [];
map.set(group, list);
}
list.push(v);
}
return map;
}, [sch, hasPermission, t]);
const list = schema?.lists[viewName] ?? schema?.lists[resolved?.objectName ?? ''];
const title = list?.title ?? t('actions.title', 'Actions');
const subtitle = list?.subtitle ?? resolved?.objectType?.description;
const handlePickVariant = useCallback(
(variant: ObjectVariant) => {
if (!schema || !resolved) return;
if (!variant.schemaName) {
executeAction(schema, resolved.objectName, variant.name, {}, setState);
} else {
const fieldsDef = schema.fields[variant.schemaName!];
const defaults = fieldsDef?.defaults ? { ...fieldsDef.defaults } : {};
setState({ kind: 'form', variant, formData: defaults });
}
},
[schema, resolved],
);
const handleSubmitForm = useCallback(
async (variant: ObjectVariant, formData: Record<string, unknown>) => {
if (!schema || !resolved) return;
setState({ kind: 'submitting', variant });
const fields = variant.schemaName ? schema.fields[variant.schemaName] : null;
const payload: Record<string, unknown> = { '@type': variant.name };
if (fields) {
for (const [name, def] of Object.entries(fields.properties)) {
if (def.update === 'serverSet') continue;
if (name in formData) {
const isSecret =
def.type.type === 'string' && (def.type.format === 'secret' || def.type.format === 'secretText');
if (isSecret && formData[name] === '*****') continue;
payload[name] = formData[name];
}
}
}
try {
const accountId = getAccountId(resolved.objectName);
const responses = await jmapSet(resolved.objectName, accountId, {
create: { 'action-0': payload },
});
const setResponse = responses[responses.length - 1];
const setResult = setResponse[1] as unknown as JmapSetResponse;
if (setResult.created && setResult.created['action-0']) {
const created = setResult.created['action-0'];
const noisyKeys = new Set(['id', 'blobId']);
const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k));
if (extraKeys.length > 0) {
const extra: Record<string, unknown> = {};
for (const k of extraKeys) extra[k] = created[k];
setState({ kind: 'result', variant, props: extra, inputData: formData });
} else {
setState({ kind: 'result', variant, props: null, inputData: formData });
}
} else if (setResult.notCreated && setResult.notCreated['action-0']) {
const err = setResult.notCreated['action-0'];
setState({
kind: 'error',
variant,
error: (err as { description?: string }).description ?? t('actions.actionFailed', 'Action failed.'),
});
} else {
setState({ kind: 'result', variant, props: null, inputData: formData });
}
} catch (e) {
setState({
kind: 'error',
variant,
error: e instanceof Error ? e.message : t('actions.actionFailed', 'Action failed.'),
});
}
},
[schema, resolved, t],
);
if (!schema || !resolved || !sch) return null;
if (state.kind === 'pick') {
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>}
</div>
<div className="space-y-6">
{Array.from(groups.entries()).map(([group, groupVariants]) => (
<div key={group}>
<h3 className="text-sm font-medium text-muted-foreground mb-2">{group}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{groupVariants.map((v) => {
const colonIdx = v.label.indexOf(':');
const actionName = colonIdx > 0 ? v.label.slice(colonIdx + 1).trim() : v.label;
return (
<Button
key={v.name}
variant="outline"
className="justify-between h-auto py-3 px-4"
disabled={!canCreate}
onClick={() => handlePickVariant(v)}
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
<span>{actionName}</span>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Button>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
if (state.kind === 'form') {
return (
<ActionFormView
schema={schema}
variant={state.variant}
formData={state.formData}
onSubmit={(data) => handleSubmitForm(state.variant, data)}
onBack={() => setState({ kind: 'pick' })}
/>
);
}
if (state.kind === 'submitting') {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">
{t('actions.executing', 'Executing {{label}}...', { label: state.variant.label })}
</p>
</div>
);
}
if (state.kind === 'result') {
return (
<ActionResultView
schema={schema}
variant={state.variant}
props={state.props}
inputData={state.inputData}
onNewAction={() => setState({ kind: 'pick' })}
/>
);
}
if (state.kind === 'error') {
return (
<div className="mx-auto max-w-xl space-y-4 pt-8">
<Card>
<CardContent className="p-6 space-y-4">
<div className="flex items-center gap-3">
<CircleAlert className="h-5 w-5 text-destructive shrink-0" />
<div>
<p className="font-medium">{t('actions.failedTitle', 'Action failed')}</p>
<p className="text-sm text-muted-foreground mt-1">{state.error}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
{t('actions.backToActions', 'Back to actions')}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return null;
}
async function executeAction(
schema: Schema,
objectName: string,
variantName: string,
formData: Record<string, unknown>,
setState: React.Dispatch<React.SetStateAction<ActionState>>,
) {
const variant = { name: variantName, label: variantName } as ObjectVariant;
setState({ kind: 'submitting', variant });
try {
const accountId = getAccountId(objectName);
const payload: Record<string, unknown> = { '@type': variantName, ...formData };
const responses = await jmapSet(objectName, accountId, {
create: { 'action-0': payload },
});
const setResponse = responses[responses.length - 1];
const setResult = setResponse[1] as unknown as JmapSetResponse;
const sch = resolveSchema(schema, objectName);
const realVariant =
sch?.type === 'multiple' ? (sch.variants.find((v) => v.name === variantName) ?? variant) : variant;
if (setResult.created && setResult.created['action-0']) {
const created = setResult.created['action-0'];
const noisyKeys = new Set(['id', 'blobId']);
const extraKeys = Object.keys(created).filter((k) => !noisyKeys.has(k));
if (extraKeys.length > 0) {
const extra: Record<string, unknown> = {};
for (const k of extraKeys) extra[k] = created[k];
setState({ kind: 'result', variant: realVariant, props: extra, inputData: formData });
} else {
setState({ kind: 'result', variant: realVariant, props: null, inputData: formData });
}
} else if (setResult.notCreated && setResult.notCreated['action-0']) {
const err = setResult.notCreated['action-0'];
setState({
kind: 'error',
variant: realVariant,
error: (err as { description?: string }).description ?? i18n.t('actions.actionFailed', 'Action failed.'),
});
} else {
setState({ kind: 'result', variant: realVariant, props: null, inputData: formData });
}
} catch (e) {
setState({
kind: 'error',
variant,
error: e instanceof Error ? e.message : i18n.t('actions.actionFailed', 'Action failed.'),
});
}
}
function ActionFormView({
schema,
variant,
formData: initialData,
onSubmit,
onBack,
}: {
schema: Schema;
variant: ObjectVariant;
formData: Record<string, unknown>;
onSubmit: (data: Record<string, unknown>) => void;
onBack: () => void;
}) {
const { t } = useTranslation();
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
const [errors, setErrors] = useState<Record<string, string>>({});
if (!variant.schemaName) return null;
const fields = schema.fields[variant.schemaName];
const form = resolveVariantForm(schema, '', '', variant.schemaName);
if (!fields || !form) return null;
const mutableSections = form.sections
.map((section) => ({
...section,
fields: section.fields.filter((ff) => {
const def = fields.properties[ff.name];
return def && def.update === 'mutable';
}),
}))
.filter((s) => s.fields.length > 0);
const handleChange = (fieldName: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [fieldName]: value }));
setErrors((prev) => {
const copy = { ...prev };
delete copy[fieldName];
return copy;
});
};
const handleSubmit = () => {
const newErrors: Record<string, string> = {};
for (const section of mutableSections) {
for (const ff of section.fields) {
const def = fields.properties[ff.name];
if (!def) continue;
const isRequired =
(def.type.type === 'string' ||
def.type.type === 'number' ||
def.type.type === 'enum' ||
def.type.type === 'objectId') &&
!('nullable' in def.type && def.type.nullable);
if (isRequired) {
const val = formData[ff.name];
if (val === undefined || val === null || val === '') {
newErrors[ff.name] = t('form.required', 'This field is required.');
}
}
}
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(formData);
};
return (
<div className="space-y-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onBack}>
{t('common.back', 'Back')}
</Button>
<h1 className="text-xl font-semibold">{variant.label}</h1>
</div>
{mutableSections.map((section, si) => (
<Card key={si}>
{section.title && (
<CardHeader className="pb-3">
<CardTitle className="text-base">{section.title}</CardTitle>
</CardHeader>
)}
<CardContent className={section.title ? 'pt-0' : ''}>
<div className="space-y-4">
{section.fields.map((ff) => {
const def = fields.properties[ff.name];
if (!def) return null;
return (
<FieldWidget
key={ff.name}
formField={ff}
field={def}
value={formData[ff.name]}
onChange={(v) => handleChange(ff.name, v)}
readOnly={false}
schema={schema}
error={errors[ff.name]}
/>
);
})}
</div>
</CardContent>
</Card>
))}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onBack}>
{t('common.cancel', 'Cancel')}
</Button>
<Button onClick={handleSubmit}>
<Zap className="mr-2 h-4 w-4" />
{t('actions.execute', 'Execute')}
</Button>
</div>
</div>
);
}
function ActionResultView({
schema,
variant,
props,
inputData,
onNewAction,
}: {
schema: Schema;
variant: ObjectVariant;
props: Record<string, unknown> | null;
inputData: Record<string, unknown>;
onNewAction: () => void;
}) {
const { t } = useTranslation();
const mergedData = useMemo(() => ({ ...inputData, ...(props ?? {}) }), [inputData, props]);
const hasData = variant.schemaName && Object.keys(mergedData).length > 0;
const visibleFields = useMemo(() => new Set(Object.keys(mergedData)), [mergedData]);
return (
<div className="space-y-6 pt-4">
<div className="flex items-center gap-3">
<CircleCheck className="h-5 w-5 text-emerald-500 shrink-0" />
<div>
<p className="font-medium">{variant.label}</p>
<p className="text-sm text-muted-foreground">
{t('actions.executedSuccess', 'Action executed successfully.')}
</p>
</div>
</div>
{hasData && variant.schemaName && (
<DynamicView
schema={schema}
objectName={variant.schemaName}
viewName={variant.schemaName}
data={mergedData}
visibleFields={visibleFields}
/>
)}
{!hasData && (
<Card>
<CardContent className="p-6 text-sm text-muted-foreground">
{t('actions.noAdditionalData', 'No additional data returned.')}
</CardContent>
</Card>
)}
<Button onClick={onNewAction}>
<Zap className="mr-2 h-4 w-4" />
{t('actions.executeAnother', 'Execute another action')}
</Button>
</div>
);
}
@@ -0,0 +1,252 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useMemo, useRef, useState, useEffect } from 'react';
import {
LineChart,
Line,
AreaChart,
Area,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from 'recharts';
function ChartSizedContainer({
height,
children,
}: {
height: number;
children: (width: number, height: number) => React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const w = entry.contentRect.width;
if (w > 0) setWidth(w);
}
});
ro.observe(el);
const w = el.getBoundingClientRect().width;
if (w > 0) setWidth(w);
return () => ro.disconnect();
}, []);
return (
<div ref={ref} style={{ height }}>
{width > 0 && children(width, height)}
</div>
);
}
import { Info } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tooltip as UiTooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getChartColor } from '@/components/ui/chart';
import { ChartTooltipContent } from '@/components/ui/chart';
import type { Chart as ChartSchema } from '../types/schema';
import type { Metric, Period } from '../types/metrics';
import {
bucketize,
bucketTimestamps,
getBucketCount,
seriesBucketValue,
formatTimeTick,
formatValue,
} from '../helpers';
interface DashboardChartProps {
chart: ChartSchema;
historySamples: Metric[];
historyWindow: { from: Date; to: Date };
period: Period;
}
export function DashboardChart({ chart, historySamples, historyWindow, period }: DashboardChartProps) {
const { from, to } = historyWindow;
const bucketCount = getBucketCount(period);
const valueFormat = chart.valueFormat ?? 'number';
const data = useMemo(() => {
const buckets = bucketize(historySamples, from, to, bucketCount);
const timestamps = bucketTimestamps(from, to, bucketCount);
const points = timestamps.map((ts, i) => {
const point: Record<string, unknown> = {
time: ts.getTime(),
timeLabel: formatTimeTick(ts, period),
};
for (const series of chart.series) {
point[series.label] = seriesBucketValue(series, buckets[i]);
}
return point;
});
if (chart.stacked) {
const lastSeen: Record<string, number> = {};
for (const point of points) {
for (const series of chart.series) {
const v = point[series.label];
if (typeof v === 'number') {
lastSeen[series.label] = v;
} else {
point[series.label] = lastSeen[series.label] ?? 0;
}
}
}
}
return points;
}, [historySamples, from, to, bucketCount, chart.series, chart.stacked, period]);
const tickFormatter = (value: number) => formatValue(value, valueFormat);
const tooltipFormatter = (value: number) => formatValue(value, valueFormat);
const renderChart = (chartWidth: number, chartHeight: number) => {
const commonProps = {
data,
width: chartWidth,
height: chartHeight,
margin: { top: 5, right: 10, left: 10, bottom: 5 },
};
const seriesElements = chart.series.map((s, i) => {
const color = getChartColor(i);
const key = s.label;
switch (chart.kind) {
case 'line':
return (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={color}
strokeWidth={2}
dot={false}
isAnimationActive={false}
connectNulls
/>
);
case 'area':
return (
<Area
key={key}
type="monotone"
dataKey={key}
stroke={color}
fill={color}
fillOpacity={0.3}
strokeWidth={2}
stackId={chart.stacked ? '1' : undefined}
isAnimationActive={false}
connectNulls
/>
);
case 'bar':
return (
<Bar
key={key}
dataKey={key}
fill={color}
stackId={chart.stacked ? '1' : undefined}
isAnimationActive={false}
/>
);
}
});
const axes = (
<>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="timeLabel"
tick={{ fontSize: 11 }}
className="text-muted-foreground"
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={tickFormatter}
tick={{ fontSize: 11 }}
className="text-muted-foreground"
tickLine={false}
axisLine={false}
width={60}
/>
<Tooltip
content={({ active, payload, label }) => (
<ChartTooltipContent
active={active}
payload={payload}
label={label as string}
formatter={tooltipFormatter}
/>
)}
/>
<Legend iconType="circle" iconSize={8} wrapperStyle={{ fontSize: '12px' }} />
</>
);
switch (chart.kind) {
case 'line':
return (
<LineChart {...commonProps}>
{axes}
{seriesElements}
</LineChart>
);
case 'area':
return (
<AreaChart {...commonProps}>
{axes}
{seriesElements}
</AreaChart>
);
case 'bar':
return (
<BarChart {...commonProps}>
{axes}
{seriesElements}
</BarChart>
);
}
};
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{chart.title}</CardTitle>
{chart.description && (
<TooltipProvider>
<UiTooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">{chart.description}</p>
</TooltipContent>
</UiTooltip>
</TooltipProvider>
)}
</div>
</CardHeader>
<CardContent>
<ChartSizedContainer height={288}>{(width, height) => renderChart(width, height)}</ChartSizedContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,157 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { AlertCircle } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useSchemaStore } from '@/stores/schemaStore';
import type { Dashboard } from '../types/schema';
import { useDashboardStore } from '../stores/dashboardStore';
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
import { useHistoryMetricsStore } from '../stores/historyMetricsStore';
import { collectHistoryMetricIds, collectLiveMetricIds, periodKey, periodWindow, deltaHistograms } from '../helpers';
import { StatCard } from './StatCard';
import { DashboardChart } from './DashboardChart';
import { PeriodSelector } from './PeriodSelector';
interface DashboardViewProps {
dashboardId: string;
section: string;
}
export function DashboardView({ dashboardId, section }: DashboardViewProps) {
const navigate = useNavigate();
const schema = useSchemaStore((s) => s.schema);
const period = useDashboardStore((s) => s.period);
const fetchHistory = useHistoryMetricsStore((s) => s.fetch);
const refreshHistory = useHistoryMetricsStore((s) => s.refresh);
const historyStatus = useHistoryMetricsStore((s) => s.status);
const historyCache = useHistoryMetricsStore((s) => s.cache);
const subscribeLive = useLiveMetricsStore((s) => s.subscribe);
const unsubscribeLive = useLiveMetricsStore((s) => s.unsubscribe);
const liveStatus = useLiveMetricsStore((s) => s.status);
const liveError = useLiveMetricsStore((s) => s.error);
const dashboards = useMemo<Dashboard[]>(() => schema?.dashboards ?? [], [schema]);
const dashboard = dashboards.find((d) => d.id === dashboardId);
useEffect(() => {
if (!dashboard && dashboards.length > 0) {
navigate(`/${section}/Dashboard/${dashboards[0].id}`, { replace: true });
}
}, [dashboard, dashboards, navigate, section]);
const historyIds = useMemo(
() => (dashboard ? collectHistoryMetricIds(dashboard.cards, dashboard.charts) : new Set<string>()),
[dashboard],
);
const liveIds = useMemo(() => (dashboard ? collectLiveMetricIds(dashboard.cards) : new Set<string>()), [dashboard]);
const cacheKey = dashboard ? `${dashboard.id}|${periodKey(period)}` : '';
const [fetchVersion, setFetchVersion] = useState(0);
useEffect(() => {
if (!dashboard || historyIds.size === 0) return;
let cancelled = false;
fetchHistory(dashboard.id, period, historyIds).then(() => {
if (!cancelled) setFetchVersion((v) => v + 1);
});
return () => {
cancelled = true;
};
}, [dashboard, period, historyIds, fetchHistory]);
const { historySamples, historyWindow } = useMemo(() => {
void fetchVersion;
const raw = historyCache.get(cacheKey)?.metrics ?? [];
return {
historySamples: deltaHistograms(raw),
historyWindow: periodWindow(period),
};
}, [historyCache, cacheKey, fetchVersion, period]);
useEffect(() => {
if (liveIds.size > 0) {
subscribeLive(liveIds);
}
return () => {
unsubscribeLive();
};
}, [liveIds, subscribeLive, unsubscribeLive]);
const isLoading = historyStatus.get(cacheKey) === 'loading';
const handleRefresh = useCallback(() => {
if (dashboard && historyIds.size > 0) {
refreshHistory(dashboard.id, period, historyIds).then(() => setFetchVersion((v) => v + 1));
}
}, [dashboard, period, historyIds, refreshHistory]);
if (!dashboard) {
if (dashboards.length === 0) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">No dashboards configured.</div>
);
}
return null;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{dashboards.length > 1 && (
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
<TabsList>
{dashboards.map((d) => (
<TabsTrigger key={d.id} value={d.id}>
{d.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{dashboards.length === 1 && <h1 className="text-xl font-semibold">{dashboard.label}</h1>}
<PeriodSelector onRefresh={handleRefresh} loading={isLoading} />
</div>
{liveStatus === 'error' && liveError && (
<div className="flex items-center gap-2 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4 shrink-0" />
{liveError}
</div>
)}
{dashboard.cards && dashboard.cards.length > 0 && (
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(220px,1fr))]">
{dashboard.cards.map((card, i) => (
<StatCard
key={`${card.title}-${i}`}
card={card}
historySamples={historySamples}
historyWindow={historyWindow}
/>
))}
</div>
)}
{dashboard.charts && dashboard.charts.length > 0 && (
<div className="space-y-4">
{dashboard.charts.map((chart, i) => (
<DashboardChart
key={`${chart.title}-${i}`}
chart={chart}
historySamples={historySamples}
historyWindow={historyWindow}
period={period}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Calendar, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useDashboardStore } from '../stores/dashboardStore';
import type { PresetKey } from '../types/metrics';
import { presetLabel, PRESET_KEYS } from '../types/metrics';
interface PeriodSelectorProps {
onRefresh: () => void;
loading?: boolean;
}
export function PeriodSelector({ onRefresh, loading }: PeriodSelectorProps) {
const { t } = useTranslation();
const period = useDashboardStore((s) => s.period);
const setPreset = useDashboardStore((s) => s.setPreset);
const setPeriod = useDashboardStore((s) => s.setPeriod);
const [customOpen, setCustomOpen] = useState(false);
const [customFrom, setCustomFrom] = useState('');
const [customTo, setCustomTo] = useState('');
const currentValue = period.kind === 'preset' ? period.preset : 'custom';
const handleSelectChange = (value: string) => {
if (value === 'custom') {
setCustomOpen(true);
} else {
setPreset(value as PresetKey);
}
};
const handleApplyCustom = () => {
if (customFrom && customTo) {
setPeriod({
kind: 'custom',
from: new Date(customFrom),
to: new Date(customTo),
});
setCustomOpen(false);
}
};
return (
<div className="flex items-center gap-2">
<Popover open={customOpen} onOpenChange={setCustomOpen}>
<div className="flex items-center gap-2">
<Select value={currentValue} onValueChange={handleSelectChange}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PRESET_KEYS.map((key) => (
<SelectItem key={key} value={key}>
{presetLabel(t, key)}
</SelectItem>
))}
<SelectItem value="custom">
<div className="flex items-center gap-2">
<Calendar className="h-3.5 w-3.5" />
{t('dashboard.customEllipsis', 'Custom...')}
</div>
</SelectItem>
</SelectContent>
</Select>
<PopoverTrigger asChild>
<span />
</PopoverTrigger>
</div>
<PopoverContent className="w-72 p-4" align="end">
<div className="space-y-3">
<h4 className="text-sm font-medium">{t('dashboard.customRange', 'Custom range')}</h4>
<div className="space-y-2">
<Label htmlFor="custom-from" className="text-xs">
{t('dashboard.from', 'From')}
</Label>
<Input
id="custom-from"
type="datetime-local"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="custom-to" className="text-xs">
{t('dashboard.to', 'To')}
</Label>
<Input
id="custom-to"
type="datetime-local"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
/>
</div>
<Button size="sm" className="w-full" onClick={handleApplyCustom}>
{t('common.apply', 'Apply')}
</Button>
</div>
</PopoverContent>
</Popover>
<Button variant="outline" size="icon" onClick={onRefresh} disabled={loading} className="h-9 w-9">
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
);
}
@@ -0,0 +1,122 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useMemo } from 'react';
import * as LucideIcons from 'lucide-react';
import { Info } from 'lucide-react';
import { LineChart, Line } from 'recharts';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { Card as CardSchema } from '../types/schema';
import type { Metric } from '../types/metrics';
import { cardValue, formatValue, sparklineData, computeDelta } from '../helpers';
import { useLiveMetricsStore } from '../stores/liveMetricsStore';
import { getChartColor } from '@/components/ui/chart';
const warnedIcons = new Set<string>();
function LucideIcon({ name, className }: { name: string; className?: string }) {
const formatted = name
.split('-')
.map((s) => s[0].toUpperCase() + s.slice(1))
.join('');
const IconComp = (LucideIcons as Record<string, unknown>)[formatted] as LucideIcons.LucideIcon | undefined;
if (!IconComp) {
if (import.meta.env.DEV && !warnedIcons.has(name)) {
warnedIcons.add(name);
console.warn(`Unknown icon name: "${name}"`);
}
return <LucideIcons.HelpCircle className={className} />;
}
return <IconComp className={className} />;
}
interface StatCardProps {
card: CardSchema;
historySamples: Metric[];
historyWindow: { from: Date; to: Date };
}
export function StatCard({ card, historySamples, historyWindow }: StatCardProps) {
const liveSnapshot = useLiveMetricsStore((s) => s.snapshot);
const value = useMemo(() => {
if (card.source === 'live') {
const liveSamples = card.metrics.map((id) => liveSnapshot.get(id)).filter((m): m is Metric => m !== undefined);
return cardValue(card, liveSamples);
}
return cardValue(card, historySamples);
}, [card, liveSnapshot, historySamples]);
const formattedValue = formatValue(value, card.format);
const { from, to } = historyWindow;
const sparkline = useMemo(() => {
if (card.source !== 'history' || !card.sparkline) return null;
return sparklineData(card, historySamples, from, to).map((v, i) => ({
v,
i,
}));
}, [card, historySamples, from, to]);
const delta = useMemo(() => {
if (card.source !== 'history' || !card.delta) return null;
return computeDelta(card, historySamples, from, to);
}, [card, historySamples, from, to]);
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2">
<LucideIcon name={card.icon} className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">{card.title}</span>
{card.description && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs">{card.description}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="mt-2 text-2xl font-bold">{formattedValue}</div>
{(delta || sparkline) && (
<div className="mt-1 flex items-center gap-2">
{delta && (
<Badge variant="secondary" className="text-xs font-normal text-muted-foreground">
{delta.direction === 'up'
? `\u2191 ${Math.abs(delta.pct)}%`
: delta.direction === 'down'
? `\u2193 ${Math.abs(delta.pct)}%`
: '\u2013'}
</Badge>
)}
{sparkline && (
<LineChart width={64} height={32} data={sparkline}>
<Line
type="monotone"
dataKey="v"
stroke={getChartColor(0)}
strokeWidth={1.5}
dot={false}
isAnimationActive={false}
/>
</LineChart>
)}
</div>
)}
</CardContent>
</Card>
);
}
+246
View File
@@ -0,0 +1,246 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Metric, MetricId, Period } from './types/metrics';
import type { Card, Series, MetricFormat, Aggregate } from './types/schema';
import { PRESET_MS, BUCKET_CONFIG, CUSTOM_BUCKET_COUNT, SPARKLINE_BUCKET_COUNT } from './types/metrics';
export function metricToScalar(m: Metric): number {
if (m['@type'] === 'Histogram') {
return m.count === 0 ? 0 : m.sum / m.count;
}
return m.count;
}
export function deltaHistograms(samples: Metric[]): Metric[] {
const nonHistograms: Metric[] = [];
const byMetric = new Map<MetricId, Metric[]>();
for (const m of samples) {
if (m['@type'] !== 'Histogram') {
nonHistograms.push(m);
continue;
}
let list = byMetric.get(m.metric);
if (!list) {
list = [];
byMetric.set(m.metric, list);
}
list.push(m);
}
const result = [...nonHistograms];
for (const [, records] of byMetric) {
records.sort((a, b) => {
const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return ta - tb;
});
for (let i = 1; i < records.length; i++) {
const prev = records[i - 1] as Metric & { '@type': 'Histogram' };
const curr = records[i] as Metric & { '@type': 'Histogram' };
const deltaCount = curr.count - prev.count;
const deltaSum = curr.sum - prev.sum;
if (deltaCount <= 0 || deltaSum < 0) continue;
result.push({
'@type': 'Histogram',
metric: curr.metric,
count: deltaCount,
sum: deltaSum,
timestamp: curr.timestamp,
});
}
}
return result;
}
export function periodWindow(p: Period): { from: Date; to: Date } {
if (p.kind === 'custom') return { from: p.from, to: p.to };
const to = new Date();
const ms = PRESET_MS[p.preset];
return { from: new Date(to.getTime() - ms), to };
}
export function periodKey(p: Period): string {
if (p.kind === 'custom') return `custom|${p.from.toISOString()}|${p.to.toISOString()}`;
return p.preset;
}
export function getBucketCount(p: Period): number {
if (p.kind === 'custom') return CUSTOM_BUCKET_COUNT;
return BUCKET_CONFIG[p.preset];
}
function aggregate(values: number[], agg: Aggregate | undefined): number {
if (values.length === 0) return 0;
const sum = values.reduce((a, b) => a + b, 0);
if (agg === 'avg') return sum / values.length;
return sum;
}
export function cardValue(card: Card, samples: Metric[]): number {
const values = samples.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar);
return aggregate(values, card.aggregate);
}
export function seriesBucketValue(series: Series, bucketSamples: Metric[]): number | null {
const values = bucketSamples.filter((m) => series.metrics.includes(m.metric)).map(metricToScalar);
if (values.length === 0) return null;
return aggregate(values, series.aggregate);
}
export function bucketize(samples: Metric[], from: Date, to: Date, bucketCount: number): Metric[][] {
const buckets: Metric[][] = Array.from({ length: bucketCount }, () => []);
const span = to.getTime() - from.getTime();
if (span <= 0) return buckets;
for (const m of samples) {
if (!m.timestamp) continue;
const t = new Date(m.timestamp).getTime();
if (t < from.getTime() || t > to.getTime()) continue;
const idx = Math.min(bucketCount - 1, Math.floor(((t - from.getTime()) / span) * bucketCount));
buckets[idx].push(m);
}
return buckets;
}
export function bucketTimestamps(from: Date, to: Date, bucketCount: number): Date[] {
const span = to.getTime() - from.getTime();
const width = span / bucketCount;
return Array.from({ length: bucketCount }, (_, i) => new Date(from.getTime() + width * (i + 0.5)));
}
export function computeDelta(
card: Card,
samples: Metric[],
from: Date,
to: Date,
): { pct: number; direction: 'up' | 'down' | 'neutral' } | null {
const mid = new Date((from.getTime() + to.getTime()) / 2);
const firstHalf = samples.filter((m) => {
if (!m.timestamp) return false;
return new Date(m.timestamp).getTime() < mid.getTime();
});
const secondHalf = samples.filter((m) => {
if (!m.timestamp) return false;
return new Date(m.timestamp).getTime() >= mid.getTime();
});
const first = aggregate(firstHalf.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar), card.aggregate);
const second = aggregate(
secondHalf.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar),
card.aggregate,
);
if (first === 0) return null;
const pct = ((second - first) / Math.abs(first)) * 100;
if (pct === 0) return { pct: 0, direction: 'neutral' };
return { pct: Math.round(pct * 10) / 10, direction: pct > 0 ? 'up' : 'down' };
}
export function sparklineData(card: Card, samples: Metric[], from: Date, to: Date): number[] {
const buckets = bucketize(samples, from, to, SPARKLINE_BUCKET_COUNT);
return buckets.map((bucket) => {
const values = bucket.filter((m) => card.metrics.includes(m.metric)).map(metricToScalar);
return aggregate(values, card.aggregate);
});
}
export function formatValue(value: number, format: MetricFormat): string {
switch (format) {
case 'number': {
if (Number.isInteger(value) && Math.abs(value) < 1000) {
return value.toString();
}
return new Intl.NumberFormat(undefined, {
notation: Math.abs(value) >= 1000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value);
}
case 'bytes': {
if (value === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
for (let i = units.length - 1; i >= 0; i--) {
const factor = 1024 ** i;
const v = value / factor;
if (v >= 1) {
const decimals = v < 10 ? 1 : 0;
return `${v.toFixed(decimals)} ${units[i]}`;
}
}
return `${value} B`;
}
case 'duration': {
if (value === 0) return '0 ms';
if (value < 1000) return `${Math.round(value)} ms`;
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
const m = Math.floor(value / 60_000);
const s = Math.floor((value % 60_000) / 1000);
if (m < 60) return s > 0 ? `${m} m ${s} s` : `${m} m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return rm > 0 ? `${h} h ${rm} m` : `${h} h`;
}
case 'percent':
return `${value.toFixed(1)}%`;
default:
return String(value);
}
}
export function formatTimeTick(date: Date, period: Period): string {
if (period.kind === 'preset') {
switch (period.preset) {
case '24h':
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
default:
return date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
}
}
const span = period.to.getTime() - period.from.getTime();
if (span <= 86_400_000) {
return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString(undefined, { month: '2-digit', day: '2-digit' });
}
export function collectHistoryMetricIds(
cards: Card[] | undefined,
charts: import('./types/schema').Chart[] | undefined,
): Set<MetricId> {
const ids = new Set<MetricId>();
if (cards) {
for (const card of cards) {
if (card.source === 'history') {
for (const id of card.metrics) ids.add(id);
}
}
}
if (charts) {
for (const chart of charts) {
for (const series of chart.series) {
for (const id of series.metrics) ids.add(id);
}
}
}
return ids;
}
export function collectLiveMetricIds(cards: Card[] | undefined): Set<MetricId> {
const ids = new Set<MetricId>();
if (cards) {
for (const card of cards) {
if (card.source === 'live') {
for (const id of card.metrics) ids.add(id);
}
}
}
return ids;
}
@@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Period, PresetKey } from '../types/metrics';
interface DashboardState {
period: Period;
setPeriod: (period: Period) => void;
setPreset: (preset: PresetKey) => void;
}
export const useDashboardStore = create<DashboardState>()(
persist(
(set) => ({
period: { kind: 'preset', preset: '24h' } as Period,
setPeriod: (period) => set({ period }),
setPreset: (preset) => set({ period: { kind: 'preset', preset } }),
}),
{
name: 'dashboard.period',
partialize: (state) => ({ period: state.period }),
},
),
);
@@ -0,0 +1,109 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import type { Metric, MetricId, Period } from '../types/metrics';
import { periodWindow, periodKey } from '../helpers';
import { getAccountId, jmapQueryAllAndGet } from '@/services/jmap/client';
import i18n from '@/i18n';
type FetchStatus = 'idle' | 'loading' | 'error';
const STALE_MS = 60_000;
interface CacheEntry {
metrics: Metric[];
fetchedAt: number;
}
interface HistoryMetricsState {
cache: Map<string, CacheEntry>;
status: Map<string, FetchStatus>;
error: Map<string, string>;
fetch: (dashboardId: string, period: Period, ids: Set<MetricId>) => Promise<Metric[]>;
invalidate: (dashboardId: string) => void;
refresh: (dashboardId: string, period: Period, ids: Set<MetricId>) => Promise<Metric[]>;
}
async function fetchMetrics(period: Period, ids: Set<MetricId>): Promise<Metric[]> {
if (ids.size === 0) return [];
const { from, to } = periodWindow(period);
const accountId = getAccountId('x:Metric');
const metricIds = Array.from(ids);
const result = await jmapQueryAllAndGet('x:Metric', accountId, {
filter: {
timestampIsGreaterThanOrEqual: from.toISOString(),
timestampIsLessThanOrEqual: to.toISOString(),
metric: metricIds,
},
});
return result.list as Metric[];
}
export const useHistoryMetricsStore = create<HistoryMetricsState>()((set, get) => ({
cache: new Map(),
status: new Map(),
error: new Map(),
fetch: async (dashboardId, period, ids) => {
const key = `${dashboardId}|${periodKey(period)}`;
const existing = get().cache.get(key);
if (existing && Date.now() - existing.fetchedAt < STALE_MS) {
return existing.metrics;
}
if (get().status.get(key) === 'loading') {
return existing?.metrics ?? [];
}
set((state) => ({
status: new Map(state.status).set(key, 'loading'),
}));
try {
const metrics = await fetchMetrics(period, ids);
set((state) => ({
cache: new Map(state.cache).set(key, { metrics, fetchedAt: Date.now() }),
status: new Map(state.status).set(key, 'idle'),
error: new Map([...state.error].filter(([k]) => k !== key)),
}));
return metrics;
} catch (e) {
const msg = e instanceof Error ? e.message : i18n.t('dashboard.failedFetchMetrics', 'Failed to fetch metrics');
set((state) => ({
status: new Map(state.status).set(key, 'error'),
error: new Map(state.error).set(key, msg),
}));
return existing?.metrics ?? [];
}
},
invalidate: (dashboardId) => {
set((state) => {
const newCache = new Map(state.cache);
for (const key of newCache.keys()) {
if (key.startsWith(`${dashboardId}|`)) {
newCache.delete(key);
}
}
return { cache: newCache };
});
},
refresh: async (dashboardId, period, ids) => {
const key = `${dashboardId}|${periodKey(period)}`;
set((state) => {
const newCache = new Map(state.cache);
newCache.delete(key);
return { cache: newCache };
});
return get().fetch(dashboardId, period, ids);
},
}));
@@ -0,0 +1,169 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import type { MetricId, Metric } from '../types/metrics';
import { apiFetch } from '@/services/api';
import i18n from '@/i18n';
type LiveStatus = 'idle' | 'connecting' | 'open' | 'error' | 'closed';
interface LiveMetricsState {
snapshot: Map<MetricId, Metric>;
subscribedIds: Set<MetricId>;
status: LiveStatus;
error: string | null;
subscribe: (ids: Set<MetricId>) => void;
unsubscribe: () => void;
handleBatch: (batch: Metric[]) => void;
}
let eventSource: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let reconnectAttempts = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const MAX_RECONNECT = 5;
const RECONNECT_DELAY = 2000;
function cleanup() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnectAttempts = 0;
}
async function openStream(ids: Set<MetricId>) {
cleanup();
if (ids.size === 0) {
useLiveMetricsStore.setState({ status: 'idle', subscribedIds: ids });
return;
}
useLiveMetricsStore.setState({ status: 'connecting', subscribedIds: ids, error: null });
try {
const tokenResponse = await apiFetch('/api/token/metrics');
const token = await tokenResponse.text();
const metricsParam = Array.from(ids).join(',');
const url = `${getOrigin()}/api/live/metrics?token=${encodeURIComponent(token)}&interval=30&metrics=${encodeURIComponent(metricsParam)}`;
const es = new EventSource(url);
eventSource = es;
reconnectAttempts = 0;
es.addEventListener('metrics', (event) => {
try {
const batch = JSON.parse(event.data) as Metric[];
useLiveMetricsStore.getState().handleBatch(batch);
} catch (e) {
console.error('Failed to parse live metrics:', e);
}
});
es.onopen = () => {
useLiveMetricsStore.setState({ status: 'open' });
};
es.onerror = () => {
if (es !== eventSource) return;
es.close();
eventSource = null;
if (reconnectAttempts < MAX_RECONNECT) {
reconnectAttempts++;
useLiveMetricsStore.setState({ status: 'connecting' });
reconnectTimer = setTimeout(() => {
const currentIds = useLiveMetricsStore.getState().subscribedIds;
openStream(currentIds);
}, RECONNECT_DELAY);
} else {
useLiveMetricsStore.setState({
status: 'error',
error: i18n.t(
'dashboard.liveMetricsDisconnected',
'Live metrics stream disconnected after multiple retries.',
),
});
}
};
} catch (e) {
useLiveMetricsStore.setState({
status: 'error',
error:
e instanceof Error ? e.message : i18n.t('dashboard.liveMetricsFailed', 'Failed to connect to live metrics.'),
});
}
}
function getOrigin(): string {
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
if (envUrl && envUrl.length > 0) return envUrl.replace(/\/+$/, '');
return window.location.origin;
}
let visibilityTimer: ReturnType<typeof setTimeout> | null = null;
function handleVisibilityChange() {
if (document.hidden) {
visibilityTimer = setTimeout(() => {
if (eventSource) {
cleanup();
useLiveMetricsStore.setState({ status: 'closed' });
}
}, 60_000);
} else {
if (visibilityTimer) {
clearTimeout(visibilityTimer);
visibilityTimer = null;
}
const state = useLiveMetricsStore.getState();
if (state.status === 'closed' && state.subscribedIds.size > 0) {
openStream(state.subscribedIds);
}
}
}
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', handleVisibilityChange);
}
export const useLiveMetricsStore = create<LiveMetricsState>()((set) => ({
snapshot: new Map(),
subscribedIds: new Set(),
status: 'idle',
error: null,
subscribe: (ids) => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
openStream(ids);
}, 200);
},
unsubscribe: () => {
if (debounceTimer) clearTimeout(debounceTimer);
cleanup();
set({ snapshot: new Map(), subscribedIds: new Set(), status: 'idle', error: null });
},
handleBatch: (batch) => {
set((state) => {
const next = new Map(state.snapshot);
for (const m of batch) {
next.set(m.metric, m);
}
return { snapshot: next };
});
},
}));
+48
View File
@@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export type MetricId = string;
export type Metric =
| { '@type': 'Counter'; metric: MetricId; count: number; timestamp?: string }
| { '@type': 'Gauge'; metric: MetricId; count: number; timestamp?: string }
| { '@type': 'Histogram'; metric: MetricId; count: number; sum: number; timestamp?: string };
export type Period = { kind: 'preset'; preset: PresetKey } | { kind: 'custom'; from: Date; to: Date };
export type PresetKey = '24h' | '7d' | '30d' | '90d';
export const PRESET_MS: Record<PresetKey, number> = {
'24h': 86_400_000,
'7d': 7 * 86_400_000,
'30d': 30 * 86_400_000,
'90d': 90 * 86_400_000,
};
export function presetLabel(t: (key: string, fallback: string) => string, key: PresetKey): string {
switch (key) {
case '24h':
return t('dashboard.preset24h', 'Last 24 hours');
case '7d':
return t('dashboard.preset7d', 'Last 7 days');
case '30d':
return t('dashboard.preset30d', 'Last 30 days');
case '90d':
return t('dashboard.preset90d', 'Last 90 days');
}
}
export const PRESET_KEYS: PresetKey[] = ['24h', '7d', '30d', '90d'];
export const BUCKET_CONFIG: Record<PresetKey, number> = {
'24h': 48,
'7d': 56,
'30d': 60,
'90d': 90,
};
export const CUSTOM_BUCKET_COUNT = 60;
export const SPARKLINE_BUCKET_COUNT = 20;
+44
View File
@@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export type Dashboard = {
id: string;
label: string;
cards?: Card[];
charts?: Chart[];
};
export type Card = {
title: string;
icon: string;
source: CardSource;
metrics: string[];
aggregate?: Aggregate;
format: MetricFormat;
description?: string;
sparkline?: boolean;
delta?: boolean;
};
export type Chart = {
title: string;
kind: ChartKind;
series: Series[];
stacked?: boolean;
valueFormat?: MetricFormat;
description?: string;
};
export type Series = {
label: string;
metrics: string[];
aggregate?: Aggregate;
};
export type Aggregate = 'sum' | 'avg';
export type ChartKind = 'line' | 'area' | 'bar';
export type CardSource = 'live' | 'history';
export type MetricFormat = 'number' | 'bytes' | 'duration' | 'percent';
@@ -0,0 +1,351 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Play, Square, RotateCcw, Plus, Check, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useSchemaStore } from '@/stores/schemaStore';
import { apiFetch, getApiBaseUrl } from '@/services/api';
import type { TraceEvent } from '../types';
import { TraceTimeline } from './TraceTimeline';
const MAX_EVENTS = 1000;
const MAX_RECONNECT = 5;
const RECONNECT_DELAY = 2000;
interface KeyValueFilter {
key: string;
label: string;
value: string;
}
type LiveTracingState =
| { kind: 'idle' }
| { kind: 'starting'; keywords: string; filters: KeyValueFilter[] }
| { kind: 'streaming'; events: TraceEvent[]; anchorTimestamp: string; eventSource: EventSource }
| { kind: 'stopped'; events: TraceEvent[]; anchorTimestamp: string }
| { kind: 'error'; events: TraceEvent[]; error: string };
export function LiveTracingPage() {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const [state, setState] = useState<LiveTracingState>({ kind: 'idle' });
const [keywords, setKeywords] = useState('');
const [filters, setFilters] = useState<KeyValueFilter[]>([]);
const [addingFilter, setAddingFilter] = useState(false);
const [newFilterKey, setNewFilterKey] = useState('');
const [newFilterValue, setNewFilterValue] = useState('');
const eventSourceRef = useRef<EventSource | null>(null);
const eventsRef = useRef<TraceEvent[]>([]);
const anchorRef = useRef<string>('');
const reconnectAttempts = useRef(0);
const keyEnum = useMemo(() => schema?.enums?.['Key'] ?? [], [schema]);
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
};
}, []);
const openStream = useCallback(async () => {
setState({ kind: 'starting', keywords, filters });
eventsRef.current = [];
anchorRef.current = '';
reconnectAttempts.current = 0;
try {
const tokenResponse = await apiFetch('/api/token/tracing');
const token = await tokenResponse.text();
const params = new URLSearchParams();
params.set('token', token);
if (keywords.trim()) {
params.set('filter', keywords.trim());
}
for (const f of filters) {
params.set(f.key, f.value);
}
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/live/tracing?${params.toString()}`;
const connectEventSource = () => {
const es = new EventSource(url);
eventSourceRef.current = es;
es.addEventListener('trace', (event) => {
try {
const batch = JSON.parse(event.data) as TraceEvent[];
const currentEvents = eventsRef.current;
for (const evt of batch) {
currentEvents.push(evt);
if (!anchorRef.current) {
anchorRef.current = evt.timestamp;
}
}
if (currentEvents.length > MAX_EVENTS) {
eventsRef.current = currentEvents.slice(currentEvents.length - MAX_EVENTS);
}
setState({
kind: 'streaming',
events: [...eventsRef.current],
anchorTimestamp: anchorRef.current,
eventSource: es,
});
} catch (e) {
console.error('Failed to parse trace event:', e);
}
});
es.onopen = () => {
reconnectAttempts.current = 0;
setState({
kind: 'streaming',
events: [...eventsRef.current],
anchorTimestamp: anchorRef.current || '',
eventSource: es,
});
};
es.onerror = () => {
if (es !== eventSourceRef.current) return;
es.close();
eventSourceRef.current = null;
if (reconnectAttempts.current < MAX_RECONNECT) {
reconnectAttempts.current++;
setTimeout(connectEventSource, RECONNECT_DELAY);
} else {
setState({
kind: 'error',
events: [...eventsRef.current],
error: t('tracing.liveDisconnected', 'Live tracing stream disconnected after multiple retries.'),
});
}
};
};
connectEventSource();
} catch (e) {
setState({
kind: 'error',
events: [],
error: e instanceof Error ? e.message : t('tracing.liveFailedStart', 'Failed to start live tracing.'),
});
}
}, [keywords, filters, t]);
const stopStream = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setState((prev) => {
if (prev.kind === 'streaming') {
return {
kind: 'stopped',
events: prev.events,
anchorTimestamp: prev.anchorTimestamp,
};
}
return prev;
});
}, []);
const resetToIdle = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
eventsRef.current = [];
anchorRef.current = '';
setState({ kind: 'idle' });
}, []);
const addFilter = useCallback(() => {
if (!newFilterKey || !newFilterValue.trim()) return;
const keyEntry = keyEnum.find((e) => e.name === newFilterKey);
setFilters((prev) => [
...prev,
{
key: newFilterKey,
label: keyEntry?.label ?? newFilterKey,
value: newFilterValue.trim(),
},
]);
setNewFilterKey('');
setNewFilterValue('');
setAddingFilter(false);
}, [newFilterKey, newFilterValue, keyEnum]);
const removeFilter = useCallback((idx: number) => {
setFilters((prev) => prev.filter((_, i) => i !== idx));
}, []);
const isIdle = state.kind === 'idle';
const isStarting = state.kind === 'starting';
const isStreaming = state.kind === 'streaming';
const isStopped = state.kind === 'stopped';
const isError = state.kind === 'error';
if (isIdle || isStarting) {
return (
<div className="mx-auto max-w-2xl space-y-6 pt-8">
<div>
<h1 className="text-2xl font-bold">{t('tracing.liveTitle', 'Live Tracing')}</h1>
<p className="mt-2 text-muted-foreground">
{t('tracing.liveSubtitle', 'Stream server events in real time.')}
</p>
</div>
<Card>
<CardContent className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('tracing.keywordsLabel', 'Keywords (optional)')}</label>
<Input
placeholder={t('tracing.keywordsPlaceholder', 'Enter keywords to filter events...')}
value={keywords}
onChange={(e) => setKeywords(e.target.value)}
/>
</div>
{filters.length > 0 && (
<div className="flex flex-wrap gap-2">
{filters.map((f, i) => (
<Badge key={i} variant="secondary" className="gap-1">
{f.label} = {f.value}
<button onClick={() => removeFilter(i)} className="ml-1 hover:text-destructive">
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
{addingFilter ? (
<div className="flex items-center gap-2">
<Select value={newFilterKey} onValueChange={setNewFilterKey}>
<SelectTrigger className="w-48">
<SelectValue placeholder={t('field.selectKey', 'Select key...')} />
</SelectTrigger>
<SelectContent className="max-h-60">
{keyEnum.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
{entry.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder={t('tracing.valuePlaceholder', 'Value...')}
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
className="flex-1"
onKeyDown={(e) => {
if (e.key === 'Enter') addFilter();
}}
/>
<Button size="icon" variant="ghost" onClick={addFilter}>
<Check className="h-4 w-4" />
</Button>
<Button size="icon" variant="ghost" onClick={() => setAddingFilter(false)}>
<X className="h-4 w-4" />
</Button>
</div>
) : (
<Button variant="outline" size="sm" onClick={() => setAddingFilter(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('tracing.addFilter', 'Add filter')}
</Button>
)}
<Button className="w-full" onClick={openStream} disabled={isStarting}>
{isStarting ? (
<>
<span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('tracing.connecting', 'Connecting...')}
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
{t('tracing.startTracing', 'Start tracing')}
</>
)}
</Button>
</CardContent>
</Card>
</div>
);
}
const events = isStreaming ? state.events : isStopped ? state.events : isError ? state.events : [];
const anchor = isStreaming ? state.anchorTimestamp : isStopped ? state.anchorTimestamp : undefined;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">{t('tracing.liveTitle', 'Live Tracing')}</h2>
{isStreaming && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
</span>
{t('tracing.streamingEvents', 'Streaming events...')}
</div>
)}
{isStopped && <span className="text-sm text-muted-foreground">{t('tracing.stopped', 'Stopped')}</span>}
</div>
<div className="flex gap-2">
{isStreaming && (
<Button variant="outline" onClick={stopStream}>
<Square className="mr-2 h-4 w-4" />
{t('tracing.stopTracing', 'Stop tracing')}
</Button>
)}
{(isStopped || isError) && (
<Button variant="outline" onClick={resetToIdle}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('tracing.restart', 'Restart')}
</Button>
)}
</div>
</div>
{isError && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{state.error}
</div>
)}
<Card>
<CardContent className="p-4">
<TraceTimeline events={events} anchorTimestamp={anchor} />
</CardContent>
</Card>
<p className="text-xs text-muted-foreground text-center">
{events.length === 1
? t('tracing.eventCount_one', '{{count}} event', { count: events.length })
: t('tracing.eventCount_other', '{{count}} events', { count: events.length })}
{events.length >= MAX_EVENTS && ' ' + t('tracing.bufferFull', '(buffer full)')}
</p>
</div>
);
}
@@ -0,0 +1,208 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, FileText, Clock, Timer, Activity, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useSchemaStore } from '@/stores/schemaStore';
import { jmapGet, getAccountId } from '@/services/jmap/client';
import { resolveObject } from '@/lib/schemaResolver';
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
import { TraceTimeline } from './TraceTimeline';
import { jmapMapToArray } from '@/lib/jmapUtils';
function normalizeTraceEvents(raw: unknown): TraceEvent[] {
const events = jmapMapToArray<Record<string, unknown>>(raw);
return events.map((evt) => ({
event: String(evt.event ?? ''),
timestamp: String(evt.timestamp ?? ''),
keyValues: normalizeKeyValues(evt.keyValues),
}));
}
function normalizeKeyValues(raw: unknown): TraceKeyValue[] {
const kvs = jmapMapToArray<Record<string, unknown>>(raw);
return kvs.map((kv) => ({
key: String(kv.key ?? ''),
value: normalizeTraceValue(kv.value),
}));
}
function normalizeTraceValue(raw: unknown): TraceValue {
if (!raw || typeof raw !== 'object') return { '@type': 'Null' };
const obj = raw as Record<string, unknown>;
const type = obj['@type'] as string;
if (type === 'List') {
return { '@type': 'List', value: jmapMapToArray<unknown>(obj.value).map(normalizeTraceValue) };
}
if (type === 'Event') {
return { '@type': 'Event', event: String(obj.event ?? ''), value: normalizeKeyValues(obj.value) };
}
return raw as TraceValue;
}
interface TraceDetailViewProps {
viewName: string;
objectId: string;
}
export function TraceDetailView({ viewName, objectId }: TraceDetailViewProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const schema = useSchemaStore((s) => s.schema);
const [events, setEvents] = useState<TraceEvent[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!schema) return;
let cancelled = false;
async function load() {
try {
const resolved = resolveObject(schema!, viewName);
if (!resolved) throw new Error(t('view.couldNotResolve', 'Could not resolve object'));
const accountId = getAccountId(resolved.objectName);
const responses = await jmapGet(resolved.objectName, accountId, [objectId]);
if (cancelled) return;
const getResult = responses.find(([name]) => name.endsWith('/get'));
if (!getResult) throw new Error(t('view.noGetResponse', 'No get response'));
const data = getResult[1] as { list?: Array<{ id: string; events?: unknown }> };
const trace = data.list?.[0];
if (!trace) throw new Error(t('tracing.traceNotFound', 'Trace not found'));
setEvents(normalizeTraceEvents(trace.events));
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : t('tracing.failedToLoadTrace', 'Failed to load trace'));
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [schema, viewName, objectId, t]);
if (loading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="space-y-4">
<Button variant="outline" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
<div className="text-destructive p-4">{error}</div>
</div>
);
}
if (!events) return null;
const eventTypeEnum = schema?.enums?.['EventType'] ?? [];
const firstEvent = events[0];
const lastEvent = events[events.length - 1];
const spanLabel = firstEvent
? (eventTypeEnum.find((e) => e.name === firstEvent.event)?.label ?? firstEvent.event)
: '-';
const dateStr = firstEvent
? new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(new Date(firstEvent.timestamp))
: '-';
const durationMs =
firstEvent && lastEvent && events.length >= 2
? new Date(lastEvent.timestamp).getTime() - new Date(firstEvent.timestamp).getTime()
: null;
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
const m = Math.floor(ms / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
return `${m}m ${s}s`;
}
return (
<div className="space-y-6">
<Button variant="outline" size="sm" onClick={() => navigate(-1)}>
<ArrowLeft className="mr-2 h-4 w-4" />
{t('common.back', 'Back')}
</Button>
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(180px,1fr))]">
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
{t('tracing.spanType', 'Span Type')}
</div>
<p className="mt-1 text-lg font-semibold truncate">{spanLabel}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{t('tracing.date', 'Date')}
</div>
<p className="mt-1 text-lg font-semibold">{dateStr}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Timer className="h-4 w-4" />
{t('tracing.duration', 'Duration')}
</div>
<p className="mt-1 text-lg font-semibold">{durationMs !== null ? formatDuration(durationMs) : '-'}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Activity className="h-4 w-4" />
{t('tracing.events', 'Events')}
</div>
<p className="mt-1 text-lg font-semibold">{events.length}</p>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-4">
<TraceTimeline events={events} anchorTimestamp={firstEvent?.timestamp} />
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,348 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { CircleDot, CircleX, Clock } from 'lucide-react';
import { useSchemaStore } from '@/stores/schemaStore';
import type { TraceEvent, TraceKeyValue, TraceValue } from '../types';
const BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
function intToBase32(input: number | string): string {
let n: bigint;
try {
n = BigInt(typeof input === 'number' ? Math.floor(input) : input);
} catch {
return String(input);
}
if (n === 0n) return 'a';
const chars: string[] = [];
while (n > 0n) {
chars.push(BASE32_ALPHABET[Number(n % 32n)]);
n = n / 32n;
}
return chars.reverse().join('');
}
function isIdKey(key: string): boolean {
return key === 'id' || key.endsWith('Id');
}
interface TraceTimelineProps {
events: TraceEvent[];
anchorTimestamp?: string;
}
export function TraceTimeline({ events, anchorTimestamp }: TraceTimelineProps) {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const containerRef = useRef<HTMLDivElement>(null);
const wasAtBottomRef = useRef(true);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
if (wasAtBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [events.length]);
const handleScroll = () => {
const el = containerRef.current;
if (!el) return;
wasAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
};
if (events.length === 0) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">
{t('tracing.noEvents', 'No trace events to display.')}
</div>
);
}
const anchorMs = anchorTimestamp ? new Date(anchorTimestamp).getTime() : new Date(events[0].timestamp).getTime();
const eventTypeEnum = schema?.enums?.['EventType'] ?? [];
const keyEnum = schema?.enums?.['Key'] ?? [];
function resolveEventLabel(eventType: string): { label: string; explanation?: string } {
const entry = eventTypeEnum.find((e) => e.name === eventType);
if (entry) return { label: entry.label, explanation: entry.explanation };
return { label: eventType };
}
function resolveKeyLabel(key: string): string {
const entry = keyEnum.find((e) => e.name === key);
return entry?.label ?? key;
}
function eventIcon(eventType: string) {
if (
eventType.includes('error') ||
eventType.includes('failed') ||
eventType.includes('invalid') ||
eventType.includes('reject')
) {
return <CircleX className="h-4 w-4 text-red-500" />;
}
if (eventType.endsWith('-start') || eventType.endsWith('-end')) {
return <Clock className="h-4 w-4 text-muted-foreground" />;
}
return <CircleDot className="h-4 w-4 text-muted-foreground" />;
}
function formatAbsoluteTime(ts: string): string {
const d = new Date(ts);
return d.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
} as Intl.DateTimeFormatOptions);
}
function formatRelativeTime(ts: string): string {
const ms = new Date(ts).getTime() - anchorMs;
if (ms <= 0) return '(+0 ms)';
if (ms < 1000) return `(+${Math.round(ms)} ms)`;
if (ms < 60_000) return `(+${(ms / 1000).toFixed(1)} s)`;
const m = Math.floor(ms / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
return `(+${m} m ${s} s)`;
}
return (
<div className="relative">
<div ref={containerRef} onScroll={handleScroll} className="max-h-[70vh] overflow-y-auto scroll-smooth">
<div className="space-y-4 p-2">
{events.map((event, idx) => {
const { label, explanation } = resolveEventLabel(event.event);
const isFallback = !eventTypeEnum.find((e) => e.name === event.event);
return (
<div key={idx} className="flex gap-3">
<div className="mt-1 shrink-0">{eventIcon(event.event)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div>
{isFallback ? (
<span className="font-mono text-sm bg-muted px-1.5 py-0.5 rounded">{label}</span>
) : (
<span className="text-sm font-semibold">{label}</span>
)}
{explanation && <p className="text-xs text-muted-foreground mt-0.5">{explanation}</p>}
</div>
<div className="shrink-0 text-right">
<div className="text-xs text-muted-foreground">{formatAbsoluteTime(event.timestamp)}</div>
<div className="text-xs text-muted-foreground/60">{formatRelativeTime(event.timestamp)}</div>
</div>
</div>
{event.keyValues.length > 0 && (
<div className="mt-2 space-y-1">
{event.keyValues.map((kv, ki) => (
<TraceKeyValueRow
key={ki}
kv={kv}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
depth={0}
/>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-card to-transparent" />
</div>
);
}
function TlsBoolean({ on }: { on: boolean }) {
const { t } = useTranslation();
return <>{on ? t('tracing.enabled', 'enabled') : t('tracing.disabled', 'disabled')}</>;
}
function TraceKeyValueRow({
kv,
resolveKeyLabel,
resolveEventLabel,
depth,
}: {
kv: TraceKeyValue;
resolveKeyLabel: (key: string) => string;
resolveEventLabel: (event: string) => { label: string; explanation?: string };
depth: number;
}) {
const label = resolveKeyLabel(kv.key);
const viewToSection = useSchemaStore((s) => s.viewToSection);
return (
<div className="flex gap-2 text-sm" style={{ paddingLeft: depth * 16 }}>
<span className="shrink-0 text-muted-foreground text-xs min-w-24 text-right pt-0.5">{label}</span>
<span className="text-xs pt-0.5">
<TraceValueDisplay
keyName={kv.key}
value={kv.value}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
viewToSection={viewToSection}
depth={depth}
/>
</span>
</div>
);
}
function TraceValueDisplay({
keyName,
value,
resolveKeyLabel,
resolveEventLabel,
viewToSection,
depth,
}: {
keyName: string;
value: TraceValue;
resolveKeyLabel: (key: string) => string;
resolveEventLabel: (event: string) => { label: string; explanation?: string };
viewToSection: Record<string, string>;
depth: number;
}) {
switch (value['@type']) {
case 'String':
return <>{value.value}</>;
case 'UnsignedInt':
case 'Integer': {
const raw = value.value;
const numericInput =
typeof raw === 'object' && raw !== null && 'source' in (raw as Record<string, unknown>)
? ((raw as Record<string, unknown>).source as string)
: raw;
if (isIdKey(keyName)) {
const encoded = intToBase32(numericInput as number | string);
if (keyName === 'queueId') {
const section = viewToSection['x:QueuedMessage'] ?? 'Management';
return (
<Link to={`/${section}/x:QueuedMessage/${encoded}`} className="text-primary underline hover:no-underline">
{encoded}
</Link>
);
}
return <span className="font-mono">{encoded}</span>;
}
const displayNum = typeof numericInput === 'number' ? numericInput : Number(numericInput);
return <>{displayNum.toLocaleString()}</>;
}
case 'Boolean': {
if (keyName === 'tls') {
return <TlsBoolean on={value.value} />;
}
return <>{String(value.value)}</>;
}
case 'Float':
return <>{value.value.toFixed(2)}</>;
case 'UTCDateTime':
return (
<>
{new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(new Date(value.value))}
</>
);
case 'Duration': {
const ms = value.value;
if (ms < 1000) return <>{Math.round(ms)} ms</>;
if (ms < 60_000) return <>{(ms / 1000).toFixed(1)} s</>;
const m = Math.floor(ms / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
return (
<>
{m} m {s} s
</>
);
}
case 'IpAddr':
return <>{value.value}</>;
case 'List': {
if (value.value.length <= 3) {
return (
<>
{value.value.map((v, i) => (
<span key={i}>
{i > 0 && ', '}
<TraceValueDisplay
keyName={keyName}
value={v}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
viewToSection={viewToSection}
depth={depth}
/>
</span>
))}
</>
);
}
return (
<div className="space-y-0.5">
{value.value.map((v, i) => (
<div key={i}>
<TraceValueDisplay
keyName={keyName}
value={v}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
viewToSection={viewToSection}
depth={depth}
/>
</div>
))}
</div>
);
}
case 'Event': {
if (depth >= 2) {
return (
<pre className="text-xs bg-muted/30 p-2 rounded whitespace-pre-wrap">
{JSON.stringify({ event: value.event, values: value.value }, null, 2)}
</pre>
);
}
const { label, explanation } = resolveEventLabel(value.event);
return (
<div className="mt-1 border-l-2 border-muted pl-3 space-y-1">
<div className="text-xs font-semibold">{label}</div>
{explanation && <div className="text-xs text-muted-foreground">{explanation}</div>}
{value.value.map((kv, i) => (
<TraceKeyValueRow
key={i}
kv={kv}
resolveKeyLabel={resolveKeyLabel}
resolveEventLabel={resolveEventLabel}
depth={depth + 1}
/>
))}
</div>
);
}
case 'Null':
return <span className="text-muted-foreground">&mdash;</span>;
default:
return <>{JSON.stringify(value)}</>;
}
}
+33
View File
@@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export interface Trace {
events: TraceEvent[];
}
export interface TraceEvent {
event: string;
keyValues: TraceKeyValue[];
timestamp: string;
}
export interface TraceKeyValue {
key: string;
value: TraceValue;
}
export type TraceValue =
| { '@type': 'String'; value: string }
| { '@type': 'UnsignedInt'; value: number }
| { '@type': 'Integer'; value: number }
| { '@type': 'Boolean'; value: boolean }
| { '@type': 'Float'; value: number }
| { '@type': 'UTCDateTime'; value: string }
| { '@type': 'Duration'; value: number }
| { '@type': 'IpAddr'; value: string }
| { '@type': 'List'; value: TraceValue[] }
| { '@type': 'Event'; event: string; value: TraceKeyValue[] }
| { '@type': 'Null' };
@@ -0,0 +1,678 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Play,
StopCircle,
Loader2,
CircleCheck,
CircleX,
TriangleAlert,
ChevronRight,
Mail,
Globe,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { apiFetch, getApiBaseUrl } from '@/services/api';
import type { DeliveryStage, MX, ReportUri, StageSeverity } from './types';
import { stageSeverity } from './types';
function SimpleAlert({ variant, children }: { variant: 'default' | 'destructive'; children: React.ReactNode }) {
return (
<div
className={`rounded-lg border p-4 ${
variant === 'destructive' ? 'border-destructive/50 bg-destructive/10 text-destructive' : 'border-border bg-card'
}`}
>
{children}
</div>
);
}
type TraceState =
| { kind: 'idle' }
| { kind: 'starting'; target: string }
| { kind: 'running'; target: string; startedAt: Date; events: DeliveryStage[]; eventSource: EventSource }
| { kind: 'completed'; target: string; startedAt: Date; completedAt: Date; events: DeliveryStage[] }
| { kind: 'failed'; target: string; events: DeliveryStage[]; error: string };
function phaseKey(stage: DeliveryStage): string {
const t = stage.type;
if (t.startsWith('mxLookup')) return 'mxLookup';
if (t.startsWith('mtaStsFetch') || t === 'mtaStsNotFound') return 'mtaStsFetch';
if (t.startsWith('tlsRptLookup') || t === 'tlsRptNotFound') return 'tlsRptLookup';
if (t.startsWith('mtaStsVerify')) return 'mtaStsVerify';
if (t.startsWith('tlsaLookup') || t === 'tlsaNotFound') return 'tlsaLookup';
if (t.startsWith('ipLookup')) return 'ipLookup';
if (t.startsWith('connection')) return 'connection';
if (t.startsWith('readGreeting')) return 'readGreeting';
if (t.startsWith('ehlo')) return 'ehlo';
if (t.startsWith('startTls')) return 'startTls';
if (t.startsWith('daneVerify')) return 'daneVerify';
if (t.startsWith('mailFrom')) return 'mailFrom';
if (t.startsWith('rcptTo')) return 'rcptTo';
if (t.startsWith('quit')) return 'quit';
if (t === 'deliveryAttemptStart') return `attempt-${(stage as { hostname: string }).hostname}`;
return t;
}
type TFn = (key: string, fallback: string, options?: Record<string, unknown>) => string;
function phaseLabelFor(t: TFn, pk: string): string {
switch (pk) {
case 'mxLookup':
return t('deliveryTrace.phase.mxLookup', 'MX Lookup');
case 'mtaStsFetch':
return t('deliveryTrace.phase.mtaStsFetch', 'MTA-STS Policy Fetch');
case 'tlsRptLookup':
return t('deliveryTrace.phase.tlsRptLookup', 'TLS-RPT Lookup');
case 'mtaStsVerify':
return t('deliveryTrace.phase.mtaStsVerify', 'MTA-STS Verify');
case 'tlsaLookup':
return t('deliveryTrace.phase.tlsaLookup', 'TLSA / DANE Lookup');
case 'ipLookup':
return t('deliveryTrace.phase.ipLookup', 'IP Lookup');
case 'connection':
return t('deliveryTrace.phase.connection', 'TCP Connection');
case 'readGreeting':
return t('deliveryTrace.phase.readGreeting', 'Read Greeting');
case 'ehlo':
return 'EHLO';
case 'startTls':
return 'STARTTLS';
case 'daneVerify':
return t('deliveryTrace.phase.daneVerify', 'DANE Verify');
case 'mailFrom':
return 'MAIL FROM';
case 'rcptTo':
return 'RCPT TO';
case 'quit':
return 'QUIT';
default:
return pk;
}
}
function phaseLabel(t: TFn, stage: DeliveryStage): string {
if (stage.type === 'deliveryAttemptStart') {
return t('deliveryTrace.attemptLabel', 'Delivery Attempt: {{hostname}}', {
hostname: (stage as { hostname: string }).hostname,
});
}
const pk = phaseKey(stage);
return phaseLabelFor(t, pk);
}
function SeverityIcon({ severity }: { severity: StageSeverity }) {
switch (severity) {
case 'pending':
return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
case 'ok':
return <CircleCheck className="h-4 w-4 text-emerald-500" />;
case 'warn':
return <TriangleAlert className="h-4 w-4 text-amber-500" />;
case 'error':
return <CircleX className="h-4 w-4 text-red-500" />;
}
}
function formatElapsed(ms: number): string {
if (ms < 1000) return `${Math.round(ms)} ms`;
return `${(ms / 1000).toFixed(1)} s`;
}
function eventBodyText(t: TFn, stage: DeliveryStage): string | null {
const stageType = stage.type;
switch (stageType) {
case 'mxLookupSuccess': {
const total = stage.mxs.reduce((n, mx) => n + mx.exchanges.length, 0);
return total === 1
? t('deliveryTrace.body.mxLookupSuccess_one', 'Resolved {{count}} MX record', { count: total })
: t('deliveryTrace.body.mxLookupSuccess_other', 'Resolved {{count}} MX records', { count: total });
}
case 'mxLookupStart':
return t('deliveryTrace.body.mxLookupStart', 'Looking up MX for {{domain}}', { domain: stage.domain });
case 'mtaStsFetchSuccess':
return t('deliveryTrace.body.mtaStsFetchSuccess', 'Policy fetched successfully');
case 'mtaStsNotFound':
return t('deliveryTrace.body.mtaStsNotFound', 'No MTA-STS policy published');
case 'tlsRptLookupSuccess': {
const n = stage.rua.length;
return n === 1
? t('deliveryTrace.body.tlsRptLookupSuccess_one', 'Found {{count}} reporting URI', { count: n })
: t('deliveryTrace.body.tlsRptLookupSuccess_other', 'Found {{count}} reporting URIs', { count: n });
}
case 'tlsRptNotFound':
return t('deliveryTrace.body.tlsRptNotFound', 'No TLS-RPT record published');
case 'ipLookupSuccess': {
const ips = stage.remoteIps;
return ips.length <= 3
? ips.join(', ')
: t('deliveryTrace.body.ipLookupOverflow', '{{shown}} +{{extra}} more', {
shown: ips.slice(0, 3).join(', '),
extra: ips.length - 3,
});
}
case 'connectionStart':
return t('deliveryTrace.body.connectionStart', 'Connecting to {{ip}}', { ip: stage.remoteIp });
case 'connectionSuccess':
return t('deliveryTrace.body.connectionSuccess', 'Connected');
case 'tlsaLookupSuccess':
return t('deliveryTrace.body.tlsaLookupSuccess', 'TLSA record found');
case 'tlsaNotFound':
return stage.reason || t('deliveryTrace.body.tlsaNotFound', 'No TLSA record found');
case 'readGreetingSuccess':
return t('deliveryTrace.body.readGreetingSuccess', 'Server greeting received');
case 'ehloSuccess':
return t('deliveryTrace.body.ehloSuccess', 'EHLO accepted');
case 'startTlsSuccess':
return t('deliveryTrace.body.startTlsSuccess', 'TLS negotiated');
case 'daneVerifySuccess':
return t('deliveryTrace.body.daneVerifySuccess', 'DANE verification passed');
case 'mtaStsVerifySuccess':
return t('deliveryTrace.body.mtaStsVerifySuccess', 'MTA-STS hostname verified');
case 'mailFromSuccess':
return t('deliveryTrace.body.mailFromSuccess', 'Sender accepted');
case 'rcptToSuccess':
return t('deliveryTrace.body.rcptToSuccess', 'Recipient accepted');
case 'quitCompleted':
return t('deliveryTrace.body.quitCompleted', 'Session closed');
default:
if ('reason' in stage && typeof (stage as { reason?: string }).reason === 'string') {
return (stage as { reason: string }).reason;
}
return null;
}
}
function MxTable({ mxs }: { mxs: MX[] }) {
const sorted = [...mxs].sort((a, b) => a.preference - b.preference);
return (
<div className="mt-1 rounded border text-xs">
{sorted.map((mx, i) =>
mx.exchanges.map((ex, j) => (
<div key={`${i}-${j}`} className="flex gap-3 px-3 py-1 border-b last:border-b-0">
<span className="w-8 text-right text-muted-foreground">{mx.preference}</span>
<span>{ex}</span>
</div>
)),
)}
</div>
);
}
function ReportUris({ rua }: { rua: ReportUri[] }) {
return (
<div className="mt-1 space-y-1">
{rua.map((uri, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
{uri.type === 'mail' ? (
<>
<Mail className="h-3 w-3 text-muted-foreground" />
<span>{uri.email}</span>
</>
) : (
<>
<Globe className="h-3 w-3 text-muted-foreground" />
<span>{uri.url}</span>
</>
)}
</div>
))}
</div>
);
}
function OpaqueObject({ data }: { data: Record<string, unknown> }) {
return (
<div className="mt-1 rounded border bg-muted/30 p-2">
<dl className="space-y-1 text-xs">
{Object.entries(data).map(([k, v]) => (
<div key={k} className="flex gap-2">
<dt className="font-medium text-muted-foreground min-w-24">{k}</dt>
<dd className="break-all">
{typeof v === 'object' && v !== null ? (
<pre className="text-xs whitespace-pre-wrap">{JSON.stringify(v, null, 2)}</pre>
) : (
String(v)
)}
</dd>
</div>
))}
</dl>
</div>
);
}
function EventRow({ stage }: { stage: DeliveryStage }) {
const { t } = useTranslation();
const severity = stageSeverity(stage);
const body = eventBodyText(t, stage);
const elapsed = 'elapsed' in stage ? (stage as { elapsed: number }).elapsed : null;
const hasDetails =
stage.type === 'mxLookupSuccess' ||
stage.type === 'mtaStsFetchSuccess' ||
stage.type === 'tlsRptLookupSuccess' ||
stage.type === 'tlsaLookupSuccess';
const content = (
<div className="flex items-start gap-3 py-2">
<div className="mt-0.5 shrink-0">
<SeverityIcon severity={severity} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{phaseLabel(t, stage)}</span>
{elapsed !== null && (
<Badge variant="secondary" className="text-xs">
{formatElapsed(elapsed)}
</Badge>
)}
</div>
{body && (
<p className={`text-xs mt-0.5 ${severity === 'error' ? 'text-red-500' : 'text-muted-foreground'}`}>{body}</p>
)}
</div>
</div>
);
if (!hasDetails) return content;
return (
<Collapsible>
{content}
<CollapsibleTrigger asChild>
<button className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-7">
<ChevronRight className="h-3 w-3" />
{t('deliveryTrace.viewDetails', 'View details')}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="ml-7">
{stage.type === 'mxLookupSuccess' && <MxTable mxs={stage.mxs} />}
{stage.type === 'mtaStsFetchSuccess' && <OpaqueObject data={stage.policy} />}
{stage.type === 'tlsRptLookupSuccess' && <ReportUris rua={stage.rua} />}
{stage.type === 'tlsaLookupSuccess' && <OpaqueObject data={stage.record} />}
</CollapsibleContent>
</Collapsible>
);
}
function useRelativeTime(startedAtMs: number | null): string {
const { t } = useTranslation();
const [tick, setTick] = useState(0);
useEffect(() => {
if (startedAtMs === null) return;
const id = setInterval(() => setTick((prev) => prev + 1), 1000);
return () => clearInterval(id);
}, [startedAtMs]);
if (startedAtMs === null) return '';
const s = tick;
if (s < 60) return t('deliveryTrace.startedAgoSec', 'Started {{s}}s ago', { s });
return t('deliveryTrace.startedAgoMin', 'Started {{m}}m {{s}}s ago', {
m: Math.floor(s / 60),
s: s % 60,
});
}
function isValidTarget(target: string): boolean {
target = target.trim();
if (!target) return false;
if (target.includes('@')) {
const [local, domain] = target.split('@');
return local.length > 0 && domain.length > 0 && domain.includes('.');
}
return target.includes('.');
}
export function DeliveryTracePage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [inputValue, setInputValue] = useState(searchParams.get('target') ?? '');
const [inputError, setInputError] = useState<string | null>(null);
const [state, setState] = useState<TraceState>({ kind: 'idle' });
const eventSourceRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const relTime = useRelativeTime(state.kind === 'running' ? state.startedAt.getTime() : null);
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const startTrace = useCallback(async () => {
const target = inputValue.trim();
if (!isValidTarget(target)) {
setInputError(t('deliveryTrace.invalidTarget', 'Enter a valid email address or domain.'));
return;
}
setInputError(null);
setState({ kind: 'starting', target });
try {
const tokenResponse = await apiFetch('/api/token/delivery');
const token = await tokenResponse.text();
const baseUrl = getApiBaseUrl();
const url = `${baseUrl}/api/live/delivery/${encodeURIComponent(target)}?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
eventSourceRef.current = es;
const events: DeliveryStage[] = [];
const startedAt = new Date();
timeoutRef.current = setTimeout(() => {
es.close();
eventSourceRef.current = null;
setState({
kind: 'failed',
target,
events: [...events],
error: t('deliveryTrace.timedOut', 'Trace timed out after 120 seconds.'),
});
}, 120_000);
es.addEventListener('event', (event) => {
try {
const batch = JSON.parse(event.data) as DeliveryStage[];
for (const stage of batch) {
if (stage.type === 'completed') {
es.close();
eventSourceRef.current = null;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setState({
kind: 'completed',
target,
startedAt,
completedAt: new Date(),
events: [...events],
});
return;
}
events.push(stage);
}
setState({
kind: 'running',
target,
startedAt,
events: [...events],
eventSource: es,
});
} catch (e) {
console.error('Failed to parse delivery event:', e);
}
});
es.onopen = () => {
setState({
kind: 'running',
target,
startedAt,
events: [],
eventSource: es,
});
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setState((prev) => {
if (prev.kind === 'completed') return prev;
return {
kind: 'failed',
target,
events: prev.kind === 'running' ? prev.events : [],
error: t('deliveryTrace.connectionLost', 'Connection lost before trace completed.'),
};
});
};
} catch (e) {
setState({
kind: 'failed',
target,
events: [],
error: e instanceof Error ? e.message : t('deliveryTrace.failedToStart', 'Failed to start trace.'),
});
}
}, [inputValue, t]);
const cancelTrace = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setState({ kind: 'idle' });
}, []);
const resetToIdle = useCallback(() => {
setState({ kind: 'idle' });
}, []);
const rawEvents = useMemo(() => (state.kind === 'idle' || state.kind === 'starting' ? [] : state.events), [state]);
const pairedEvents = useMemo(() => {
const result: DeliveryStage[] = [];
const pendingByPhase = new Map<string, number>();
for (const evt of rawEvents) {
const pk = phaseKey(evt);
const severity = stageSeverity(evt);
if (severity === 'pending') {
pendingByPhase.set(pk, result.length);
result.push(evt);
} else {
const pendingIdx = pendingByPhase.get(pk);
if (pendingIdx !== undefined) {
result[pendingIdx] = evt;
pendingByPhase.delete(pk);
} else {
result.push(evt);
}
}
}
return result;
}, [rawEvents]);
if (state.kind === 'idle' || state.kind === 'starting') {
return (
<div className="mx-auto max-w-2xl space-y-6 pt-8">
<div>
<h1 className="text-2xl font-bold">{t('deliveryTrace.title', 'Delivery Trace')}</h1>
<p className="mt-2 text-muted-foreground">
{t(
'deliveryTrace.subtitle',
'Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.',
)}
</p>
</div>
<Card>
<CardContent className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t('deliveryTrace.targetLabel', 'Target address or domain')}
</label>
<div className="flex gap-2">
<Input
placeholder={t('deliveryTrace.targetPlaceholder', 'john@example.org or example.org')}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setInputError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') startTrace();
}}
autoFocus
className="flex-1"
/>
<Button onClick={startTrace} disabled={state.kind === 'starting'}>
{state.kind === 'starting' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span className="ml-2">{t('deliveryTrace.startTrace', 'Start trace')}</span>
</Button>
</div>
{inputError && <p className="text-xs text-destructive">{inputError}</p>}
</div>
</CardContent>
</Card>
</div>
);
}
const events = pairedEvents;
const target = state.target;
const isRunning = state.kind === 'running';
const isCompleted = state.kind === 'completed';
const isFailed = state.kind === 'failed';
const groups: { attempt: DeliveryStage | null; events: DeliveryStage[] }[] = [];
let currentGroup: { attempt: DeliveryStage | null; events: DeliveryStage[] } = {
attempt: null,
events: [],
};
for (const evt of events) {
if (evt.type === 'deliveryAttemptStart') {
if (currentGroup.events.length > 0 || currentGroup.attempt) {
groups.push(currentGroup);
}
currentGroup = { attempt: evt, events: [] };
} else {
currentGroup.events.push(evt);
}
}
if (currentGroup.events.length > 0 || currentGroup.attempt) {
groups.push(currentGroup);
}
const attemptCount = events.filter((e) => e.type === 'deliveryAttemptStart').length;
const lastEvent = events[events.length - 1];
const hasSuccessfulDelivery = lastEvent?.type === 'quitCompleted' || events.some((e) => e.type === 'rcptToSuccess');
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">
{isRunning
? t('deliveryTrace.tracingDelivery', 'Tracing delivery to {{target}}', { target })
: isCompleted
? t('deliveryTrace.traceCompletedFor', 'Trace completed for {{target}}', { target })
: t('deliveryTrace.traceFailedFor', 'Trace failed for {{target}}', { target })}
</h2>
{isRunning && <p className="text-sm text-muted-foreground">{relTime}</p>}
</div>
<div className="flex gap-2">
{isRunning && (
<Button variant="outline" onClick={cancelTrace}>
<StopCircle className="mr-2 h-4 w-4" />
{t('deliveryTrace.cancelTrace', 'Cancel trace')}
</Button>
)}
{(isCompleted || isFailed) && (
<Button variant="outline" onClick={resetToIdle}>
{t('deliveryTrace.runAnother', 'Run another trace')}
</Button>
)}
</div>
</div>
{isCompleted && (
<SimpleAlert variant="default">
<div className="flex items-center gap-2">
{hasSuccessfulDelivery ? (
<CircleCheck className="h-4 w-4 text-emerald-500" />
) : (
<CircleX className="h-4 w-4 text-red-500" />
)}
<span className="text-sm font-medium">
{hasSuccessfulDelivery
? t('deliveryTrace.deliverySuccessful', 'Delivery successful')
: t('deliveryTrace.deliveryFailed', 'Delivery failed')}
</span>
<span className="text-sm text-muted-foreground">
{attemptCount === 1
? t('deliveryTrace.attemptCount_one', '{{count}} attempt', { count: attemptCount })
: t('deliveryTrace.attemptCount_other', '{{count}} attempts', { count: attemptCount })}
{state.kind === 'completed' && (
<>
{' '}
{t('deliveryTrace.inDuration', 'in {{elapsed}}', {
elapsed: formatElapsed(state.completedAt.getTime() - state.startedAt.getTime()),
})}
</>
)}
</span>
</div>
</SimpleAlert>
)}
{isFailed && (
<SimpleAlert variant="destructive">
<p className="text-sm">{state.error}</p>
</SimpleAlert>
)}
<Card>
<CardContent className="p-4">
<div className="space-y-0">
{groups.map((group, gi) => (
<div key={gi}>
{group.attempt && (
<div className="mt-3 mb-1">
<EventRow stage={group.attempt} />
</div>
)}
<div className={group.attempt ? 'ml-6 border-l-2 border-muted/40 pl-4' : ''}>
{group.events.map((evt, ei) => (
<EventRow key={`${gi}-${ei}`} stage={evt} />
))}
</div>
</div>
))}
</div>
{isRunning && (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex gap-1">
<span className="animate-pulse">.</span>
<span className="animate-pulse" style={{ animationDelay: '0.2s' }}>
.
</span>
<span className="animate-pulse" style={{ animationDelay: '0.4s' }}>
.
</span>
</div>
{t('deliveryTrace.waiting', 'Waiting for more events')}
</div>
)}
</CardContent>
</Card>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export type DeliveryStage =
| { type: 'mxLookupStart'; domain: string }
| { type: 'mxLookupSuccess'; mxs: MX[]; elapsed: number }
| { type: 'mxLookupError'; reason: string; elapsed: number }
| { type: 'mtaStsFetchStart' }
| { type: 'mtaStsFetchSuccess'; policy: Policy; elapsed: number }
| { type: 'mtaStsFetchError'; reason: string; elapsed: number }
| { type: 'mtaStsNotFound'; elapsed: number }
| { type: 'tlsRptLookupStart' }
| { type: 'tlsRptLookupSuccess'; rua: ReportUri[]; elapsed: number }
| { type: 'tlsRptLookupError'; reason: string; elapsed: number }
| { type: 'tlsRptNotFound'; elapsed: number }
| { type: 'deliveryAttemptStart'; hostname: string }
| { type: 'mtaStsVerifySuccess' }
| { type: 'mtaStsVerifyError'; reason: string }
| { type: 'tlsaLookupStart' }
| { type: 'tlsaLookupSuccess'; record: Tlsa; elapsed: number }
| { type: 'tlsaNotFound'; elapsed: number; reason: string }
| { type: 'tlsaLookupError'; elapsed: number; reason: string }
| { type: 'ipLookupStart' }
| { type: 'ipLookupSuccess'; remoteIps: string[]; elapsed: number }
| { type: 'ipLookupError'; reason: string; elapsed: number }
| { type: 'connectionStart'; remoteIp: string }
| { type: 'connectionSuccess'; elapsed: number }
| { type: 'connectionError'; elapsed: number; reason: string }
| { type: 'readGreetingStart' }
| { type: 'readGreetingSuccess'; elapsed: number }
| { type: 'readGreetingError'; elapsed: number; reason: string }
| { type: 'ehloStart' }
| { type: 'ehloSuccess'; elapsed: number }
| { type: 'ehloError'; elapsed: number; reason: string }
| { type: 'startTlsStart' }
| { type: 'startTlsSuccess'; elapsed: number }
| { type: 'startTlsError'; elapsed: number; reason: string }
| { type: 'daneVerifySuccess' }
| { type: 'daneVerifyError'; reason: string }
| { type: 'mailFromStart' }
| { type: 'mailFromSuccess'; elapsed: number }
| { type: 'mailFromError'; reason: string; elapsed: number }
| { type: 'rcptToStart' }
| { type: 'rcptToSuccess'; elapsed: number }
| { type: 'rcptToError'; reason: string; elapsed: number }
| { type: 'quitStart' }
| { type: 'quitCompleted'; elapsed: number }
| { type: 'completed' };
export interface MX {
exchanges: string[];
preference: number;
}
export type ReportUri = { type: 'mail'; email: string } | { type: 'http'; url: string };
export type Policy = Record<string, unknown>;
export type Tlsa = Record<string, unknown>;
export type StageSeverity = 'pending' | 'ok' | 'warn' | 'error';
export function stageSeverity(stage: DeliveryStage): StageSeverity {
const t = stage.type;
if (t === 'completed') return 'ok';
if (t === 'deliveryAttemptStart') return 'ok';
if (t.endsWith('Error')) return 'error';
if (t.endsWith('NotFound')) return 'warn';
if (t.endsWith('Start')) return 'pending';
return 'ok';
}
+187
View File
@@ -0,0 +1,187 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import * as React from 'react';
const TOAST_LIMIT = 5;
const TOAST_REMOVE_DELAY = 300;
const TOAST_AUTO_DISMISS_MS = 5000;
type ToastVariant = 'default' | 'destructive' | 'success' | 'warning' | 'info';
type ToasterToast = {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
variant?: ToastVariant;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type Action =
| {
type: 'ADD_TOAST';
toast: ToasterToast;
}
| {
type: 'UPDATE_TOAST';
toast: Partial<ToasterToast>;
}
| {
type: 'DISMISS_TOAST';
toastId?: ToasterToast['id'];
}
| {
type: 'REMOVE_TOAST';
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
setTimeout(dismiss, TOAST_AUTO_DISMISS_MS);
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, []);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };
export type { ToasterToast, ToastVariant };
+67
View File
@@ -0,0 +1,67 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
import { resolveObject } from '@/lib/schemaResolver';
export function usePermissions() {
const { permissions, edition, hasPermission, hasObjectPermission } = useAccountStore();
const schema = useSchemaStore((s) => s.schema);
function canViewObject(viewName: string): boolean {
if (!schema) return false;
const resolved = resolveObject(schema, viewName);
if (!resolved) return false;
return hasObjectPermission(resolved.permissionPrefix, 'Get');
}
function canCreateObject(viewName: string): boolean {
if (!schema) return false;
const resolved = resolveObject(schema, viewName);
if (!resolved) return false;
if (resolved.objectType.type === 'singleton') return false;
return hasObjectPermission(resolved.permissionPrefix, 'Create');
}
function canUpdateObject(viewName: string): boolean {
if (!schema) return false;
const resolved = resolveObject(schema, viewName);
if (!resolved) return false;
return hasObjectPermission(resolved.permissionPrefix, 'Update');
}
function canDestroyObject(viewName: string): boolean {
if (!schema) return false;
const resolved = resolveObject(schema, viewName);
if (!resolved) return false;
if (resolved.objectType.type === 'singleton') return false;
return hasObjectPermission(resolved.permissionPrefix, 'Destroy');
}
function isEnterpriseHidden(enterprise?: boolean): boolean {
if (!enterprise) return false;
return edition === 'oss';
}
function isEnterpriseDisabled(enterprise?: boolean): boolean {
if (!enterprise) return false;
return edition === 'community';
}
return {
permissions,
edition,
hasPermission,
hasObjectPermission,
canViewObject,
canCreateObject,
canUpdateObject,
canDestroyObject,
isEnterpriseHidden,
isEnterpriseDisabled,
};
}
+364
View File
@@ -0,0 +1,364 @@
{
"accounts": "Accounts",
"actions": {
"actionFailed": "Action failed.",
"backToActions": "Back to actions",
"execute": "Execute",
"executeAnother": "Execute another action",
"executedSuccess": "Action executed successfully.",
"executing": "Executing {{label}}...",
"failedTitle": "Action failed",
"noAdditionalData": "No additional data returned.",
"otherGroup": "Other",
"title": "Actions"
},
"bootstrap": {
"clipboardBlocked": "Your browser blocked clipboard access.",
"complete": "Setup complete",
"configuredSuccessfully": "Stalwart has been configured successfully.",
"copyFailed": "Copy failed",
"credentialsCreated": "Your administrator account has been created. Write these down now: the password will not be shown again.",
"emptyForm": "Setup form is empty. The server did not return any bootstrap fields.",
"failedToComplete": "Failed to complete setup.",
"failedToLoad": "Failed to load bootstrap state.",
"finishSetup": "Finish setup",
"loadingSetup": "Loading setup...",
"nextStepBody": "restart Stalwart for the new configuration to take effect. Once restarted, sign in with the credentials above to continue administering your server.",
"nextStepLabel": "Next step:",
"noConfirm": "The server did not confirm the update.",
"stepOf": "Step {{current}} of {{total}}",
"welcome": "Welcome to Stalwart",
"welcomeSubtitle": "Let's get your server set up."
},
"common": {
"apply": "Apply",
"back": "Back",
"cancel": "Cancel",
"clear": "Clear",
"confirm": "Confirm",
"continue": "Continue",
"create": "Create",
"delete": "Delete",
"loading": "Loading...",
"next": "Next",
"no": "No",
"noResultsDot": "No results.",
"save": "Save",
"search": "Search",
"searchPlaceholder": "Search...",
"yes": "Yes",
"close": "Close"
},
"dashboard": {
"customEllipsis": "Custom...",
"customRange": "Custom range",
"failedFetchMetrics": "Failed to fetch metrics",
"from": "From",
"liveMetricsDisconnected": "Live metrics stream disconnected after multiple retries.",
"liveMetricsFailed": "Failed to connect to live metrics.",
"preset24h": "Last 24 hours",
"preset30d": "Last 30 days",
"preset7d": "Last 7 days",
"preset90d": "Last 90 days",
"to": "To"
},
"deliveryTrace": {
"attemptCount_one": "{{count}} attempt",
"attemptCount_other": "{{count}} attempts",
"attemptLabel": "Delivery Attempt: {{hostname}}",
"body": {
"connectionStart": "Connecting to {{ip}}",
"connectionSuccess": "Connected",
"daneVerifySuccess": "DANE verification passed",
"ehloSuccess": "EHLO accepted",
"ipLookupOverflow": "{{shown}} +{{extra}} more",
"mailFromSuccess": "Sender accepted",
"mtaStsFetchSuccess": "Policy fetched successfully",
"mtaStsNotFound": "No MTA-STS policy published",
"mtaStsVerifySuccess": "MTA-STS hostname verified",
"mxLookupStart": "Looking up MX for {{domain}}",
"mxLookupSuccess_one": "Resolved {{count}} MX record",
"mxLookupSuccess_other": "Resolved {{count}} MX records",
"quitCompleted": "Session closed",
"rcptToSuccess": "Recipient accepted",
"readGreetingSuccess": "Server greeting received",
"startTlsSuccess": "TLS negotiated",
"tlsRptLookupSuccess_one": "Found {{count}} reporting URI",
"tlsRptLookupSuccess_other": "Found {{count}} reporting URIs",
"tlsRptNotFound": "No TLS-RPT record published",
"tlsaLookupSuccess": "TLSA record found",
"tlsaNotFound": "No TLSA record found"
},
"cancelTrace": "Cancel trace",
"connectionLost": "Connection lost before trace completed.",
"deliveryFailed": "Delivery failed",
"deliverySuccessful": "Delivery successful",
"failedToStart": "Failed to start trace.",
"inDuration": "in {{elapsed}}",
"invalidTarget": "Enter a valid email address or domain.",
"phase": {
"connection": "TCP Connection",
"daneVerify": "DANE Verify",
"ipLookup": "IP Lookup",
"mtaStsFetch": "MTA-STS Policy Fetch",
"mtaStsVerify": "MTA-STS Verify",
"mxLookup": "MX Lookup",
"readGreeting": "Read Greeting",
"tlsRptLookup": "TLS-RPT Lookup",
"tlsaLookup": "TLSA / DANE Lookup"
},
"runAnother": "Run another trace",
"startTrace": "Start trace",
"startedAgoMin": "Started {{m}}m {{s}}s ago",
"startedAgoSec": "Started {{s}}s ago",
"subtitle": "Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.",
"targetLabel": "Target address or domain",
"targetPlaceholder": "john@example.org or example.org",
"timedOut": "Trace timed out after 120 seconds.",
"title": "Delivery Trace",
"traceCompletedFor": "Trace completed for {{target}}",
"traceFailedFor": "Trace failed for {{target}}",
"tracingDelivery": "Tracing delivery to {{target}}",
"viewDetails": "View details",
"waiting": "Waiting for more events"
},
"duration": {
"days": "Days",
"hours": "Hours",
"milliseconds": "Milliseconds",
"minutes": "Minutes",
"seconds": "Seconds"
},
"enterprise": {
"featureDisabled": "This feature requires an Enterprise license.",
"trialTitle": "Unlock Enterprise Features",
"trialDescription": "Get access to advanced features including multi-tenancy, AI-powered spam filtering, alerts, and more.",
"trialButton": "Start 30-Day Free Trial",
"requestTrial": "Request a free trial to unlock this feature.",
"ossHidden": "This feature is not available in the open-source edition."
},
"errorBoundary": {
"title": "Something went wrong",
"tryAgain": "Try Again"
},
"errors": {
"unexpectedError": "An unexpected error occurred.",
"viewNotFound": "View not found",
"loadSchemaFailed": "Failed to load the admin panel configuration.",
"notFound": "The requested resource was not found."
},
"expression": {
"addCondition": "Add condition",
"condition": "Condition",
"defaultValue": "Default value",
"else": "ELSE",
"if": "IF",
"result": "Result",
"then": "THEN",
"value": "Value"
},
"field": {
"add": "Add",
"addItem": "Add item",
"contentModified": "Content modified (will be saved as a new blob)",
"enable": "Enable",
"key": "Key...",
"loadingContent": "Loading content...",
"noMatches": "No matches",
"noPermissionToView": "You do not have permission to view {{name}}",
"none": "None",
"notSet": "Not set",
"optional": "(optional)",
"required": "required",
"selectEllipsis": "Select...",
"selectKey": "Select key...",
"selectOptions": "Select options...",
"type": "Type",
"typeAndPressEnter": "Type and press Enter...",
"unknownObjectType": "Unknown object type: {{name}}"
},
"filters": {
"after": "After",
"all": "All",
"before": "Before"
},
"form": {
"correctErrorsBelow": "Please correct the errors below.",
"createTitle": "Create {{name}}",
"createdServerGenerated": "The following values were generated by the server. Please save them now, they may not be retrievable later.",
"createdSuccess": "Created successfully",
"deleteDescription": "This action cannot be undone. This will permanently delete this {{name}}.",
"deleteTitle": "Delete {{name}}?",
"deletedSuccess": "{{name}} deleted successfully",
"editTitle": "Edit {{name}}",
"editTitleWithValue": "Edit {{name}}: {{value}}",
"failedToDelete": "Failed to delete.",
"failedToLoadData": "Failed to load data.",
"failedToSave": "Failed to save.",
"invalidValue": "Invalid value.",
"item": "item",
"leave": "Leave",
"loadingData": "Loading data...",
"loadingSchema": "Loading schema...",
"maxLengthIs": "Maximum length is {{max}}.",
"maxValueIs": "Maximum value is {{max}}.",
"minLengthIs": "Minimum length is {{min}}.",
"minValueIs": "Minimum value is {{min}}.",
"noChangesToSave": "No changes to save",
"objectNotFound": "Object not found.",
"otpCodeRequired": "Enter your current authenticator code to authorise this change.",
"required": "This field is required.",
"savedSuccess": "Saved successfully",
"selectType": "Select type...",
"stay": "Stay",
"unsavedChanges": "You have unsaved changes. Are you sure you want to leave?",
"unsavedChangesLabel": "Unsaved changes",
"unsavedChangesTitle": "Unsaved changes"
},
"globalSearch": {
"create": "Create",
"fields": "Fields",
"formSections": "Form Sections",
"list": "List",
"noResults": "No results found.",
"pages": "Pages",
"settings": "Settings"
},
"jmapErrors": {
"addressBookHasContents": "This address book has contacts. Remove them first.",
"alreadyExists": "An item with the same identifier already exists.",
"calendarHasEvent": "This calendar contains events. Remove them first.",
"forbidden": "You do not have permission to perform this action.",
"invalidForeignKey": "This item references another item that does not exist.",
"mailboxHasChild": "This mailbox has child mailboxes. Delete them first.",
"mailboxHasEmail": "This mailbox contains messages. Remove them first.",
"nodeHasChildren": "This item has children. Delete them first.",
"notFound": "The item was not found. It may have been already deleted.",
"objectIsLinked": "This item is referenced by other items and cannot be deleted.",
"overQuota": "The operation exceeds your storage quota.",
"primaryKeyViolation": "An item with the same key already exists.",
"rateLimit": "Too many requests. Please try again later.",
"singleton": "This item is a singleton and cannot be deleted.",
"tooLarge": "The item is too large.",
"unexpected": "Unexpected error ({{type}}).",
"validationFailed": "The data did not pass validation."
},
"list": {
"actionWithCount": "{{action}} ({{count}} {{name}})",
"actions": "Actions",
"allItemsSelected": "All {{total}} items matching filters are selected.",
"allPageSelected": "All {{count}} items on this page are selected.",
"bulkErrorGrouped": "{{count}} items: {{message}}",
"bulkErrorMore": "…and {{count}} more.",
"bulkFailDelete_one": "Failed to delete {{count}} item.",
"bulkFailDelete_other": "Failed to delete {{count}} items.",
"bulkFailUpdate_one": "Failed to update {{count}} item.",
"bulkFailUpdate_other": "Failed to update {{count}} items.",
"bulkMixedDelete": "{{success}} deleted, {{failed}} failed.",
"bulkMixedUpdate": "{{success}} updated, {{failed}} failed.",
"bulkSuccessDelete_one": "{{count}} item deleted successfully.",
"bulkSuccessDelete_other": "{{count}} items deleted successfully.",
"bulkSuccessUpdate_one": "{{count}} item updated successfully.",
"bulkSuccessUpdate_other": "{{count}} items updated successfully.",
"clearSelection": "Clear selection",
"confirmDescription": "Are you sure you want to proceed with: {{action}}?",
"confirmTitle": "Confirm Action",
"create": "Create {{name}}",
"delete": "Delete",
"deleteNotConfirmed": "Delete failed: item was not confirmed as destroyed by the server.",
"filterPlaceholder": "Search {{label}}...",
"filters": "Filters",
"massAction": "Actions",
"next": "Next",
"noResults": "No results found",
"previous": "Previous",
"resetFilters": "Reset",
"searchFilters": "Search",
"selectAll": "Select all",
"selectAllMatching": "Select all {{total}} items matching filters",
"selectFilterPlaceholder": "Select {{label}}...",
"selectItem": "Select item",
"showing": "Showing {{from}}-{{to}} of {{total}} {{name}}",
"showingItems": "Showing {{count}} items",
"sort": "Sort",
"unknownError": "Unknown error"
},
"login": {
"continue": "Continue",
"error": "An unexpected error occurred",
"prompt": "Enter your account name to continue",
"usernamePlaceholder": "user@example.com"
},
"logo": {
"alt": "Logo",
"stalwartAlt": "Stalwart Logo"
},
"logout": "Logout",
"oauth": {
"backToLogin": "Back to login",
"discoveryFailed": "Discovery failed for \"{{username}}\": {{status}} {{statusText}}",
"error": "Authentication failed",
"missingData": "Missing OAuth session data. Please try logging in again.",
"missingParams": "Missing authorization code or state parameter",
"processing": "Completing sign in...",
"stateMismatch": "State parameter mismatch. Please try logging in again.",
"tokenExchangeFailed": "Token exchange failed: {{status}} {{statusText}}"
},
"otp": {
"codeIncorrect": "That code is incorrect. Make sure your authenticator clock is in sync.",
"confirmationCodeLabel": "Confirmation code",
"currentCodeLabel": "Current code",
"currentCodePrompt": "Enter your current 6-digit code to authorise any change to this account (including disabling two-factor authentication).",
"disable": "Disable",
"enterCodePrompt": "Enter the code shown in your authenticator.",
"qrCodeAlt": "TOTP QR code",
"scanDescription": "Scan the QR code below with Google Authenticator, 1Password, Authy, or any other TOTP app, then enter the 6-digit code it shows to confirm setup.",
"scanPrompt": "Scan with your authenticator app",
"setUp": "Set up TOTP",
"statusDisabled": "Two-factor authentication is not enabled.",
"statusEnabled": "Two-factor authentication is enabled."
},
"rate": {
"count": "Count",
"per": "per",
"periodValue": "Period value"
},
"sections": "Sections",
"toggleTheme": "Toggle theme",
"tracing": {
"addFilter": "Add filter",
"bufferFull": "(buffer full)",
"connecting": "Connecting...",
"date": "Date",
"disabled": "disabled",
"duration": "Duration",
"enabled": "enabled",
"eventCount_one": "{{count}} event",
"eventCount_other": "{{count}} events",
"events": "Events",
"failedToLoadTrace": "Failed to load trace",
"keywordsLabel": "Keywords (optional)",
"keywordsPlaceholder": "Enter keywords to filter events...",
"liveDisconnected": "Live tracing stream disconnected after multiple retries.",
"liveFailedStart": "Failed to start live tracing.",
"liveSubtitle": "Stream server events in real time.",
"liveTitle": "Live Tracing",
"noEvents": "No trace events to display.",
"restart": "Restart",
"spanType": "Span Type",
"startTracing": "Start tracing",
"stopTracing": "Stop tracing",
"stopped": "Stopped",
"streamingEvents": "Streaming events...",
"traceNotFound": "Trace not found",
"valuePlaceholder": "Value..."
},
"tryEnterprise": "Try Enterprise",
"userMenu": "User menu",
"view": {
"couldNotResolve": "Could not resolve object",
"failedToLoad": "Failed to load",
"noGetResponse": "No get response",
"objectNotFound": "Object not found"
}
}
+27
View File
@@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './en.json';
const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur'];
i18n.use(initReactI18next).init({
resources: { en: { translation: en } },
lng: navigator.language?.split('-')[0] || 'en',
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
export function setLocale(locale: string) {
const lang = locale.split('_')[0].split('-')[0];
i18n.changeLanguage(lang);
const isRtl = RTL_LANGUAGES.includes(lang);
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
}
export default i18n;
+141
View File
@@ -0,0 +1,141 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
@import 'tailwindcss';
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0.017 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0.017 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0.017 285.823);
--content-background: oklch(0.97 0.003 285.823);
--primary: oklch(0.205 0.017 285.823);
--primary-foreground: oklch(0.985 0.002 285.823);
--secondary: oklch(0.965 0.005 285.823);
--secondary-foreground: oklch(0.205 0.017 285.823);
--muted: oklch(0.93 0.005 285.823);
--muted-foreground: oklch(0.556 0.015 285.823);
--accent: oklch(0.965 0.005 285.823);
--accent-foreground: oklch(0.205 0.017 285.823);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0.002 285.823);
--border: oklch(0.922 0.007 285.823);
--input: oklch(0.922 0.007 285.823);
--ring: oklch(0.708 0.015 285.823);
--radius: 0.5rem;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0.002 285.823);
--card: oklch(0.205 0.007 285.823);
--card-foreground: oklch(0.985 0.002 285.823);
--popover: oklch(0.205 0.007 285.823);
--popover-foreground: oklch(0.985 0.002 285.823);
--content-background: oklch(0.115 0.005 285.823);
--primary: oklch(0.985 0.002 285.823);
--primary-foreground: oklch(0.205 0.017 285.823);
--secondary: oklch(0.274 0.009 285.823);
--secondary-foreground: oklch(0.985 0.002 285.823);
--muted: oklch(0.274 0.009 285.823);
--muted-foreground: oklch(0.708 0.015 285.823);
--accent: oklch(0.274 0.009 285.823);
--accent-foreground: oklch(0.985 0.002 285.823);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0.002 285.823);
--border: oklch(0.274 0.009 285.823);
--input: oklch(0.274 0.009 285.823);
--ring: oklch(0.553 0.013 285.823);
--chart-1: 220 70% 60%;
--chart-2: 160 60% 55%;
--chart-3: 30 80% 60%;
--chart-4: 280 65% 65%;
--chart-5: 340 75% 60%;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-content-background: var(--content-background);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
.dark input[type='number'] {
color-scheme: dark;
}
+15
View File
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
let cached: string | undefined;
export function getBasePath(): string {
if (cached !== undefined) return cached;
const base = document.querySelector('base')?.getAttribute('href') ?? '/';
cached = base.replace(/\/+$/, '');
return cached;
}
+108
View File
@@ -0,0 +1,108 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
const isDev = import.meta.env.DEV;
function isEnabled(envVar: string | undefined): boolean {
if (!isDev || !envVar) return false;
const v = envVar.toLowerCase();
return v !== '' && v !== 'false' && v !== '0';
}
export const debugJmap = isEnabled(import.meta.env.VITE_DEBUG_JMAP);
export const debugForms = isEnabled(import.meta.env.VITE_DEBUG_FORMS);
type JmapCall = [string, Record<string, unknown>, string];
function summarizeArgs(args: Record<string, unknown>): string {
const parts: string[] = [];
if (typeof args.accountId === 'string') {
parts.push(`account=${truncate(args.accountId, 12)}`);
}
if (Array.isArray(args.ids)) {
parts.push(`ids=${args.ids.length}`);
} else if (args.ids === null) {
parts.push('ids=all');
}
if (args.create && typeof args.create === 'object') {
parts.push(`create=${Object.keys(args.create).length}`);
}
if (args.update && typeof args.update === 'object') {
parts.push(`update=${Object.keys(args.update).length}`);
}
if (Array.isArray(args.destroy)) {
parts.push(`destroy=${args.destroy.length}`);
}
if (Array.isArray(args.list)) {
parts.push(`list=${args.list.length}`);
}
if (typeof args.total === 'number') {
parts.push(`total=${args.total}`);
}
return parts.join(' ');
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function buildSummaryRows(calls: unknown): Array<Record<string, string>> {
if (!Array.isArray(calls)) return [];
return calls.map((call, idx) => {
if (!Array.isArray(call) || call.length < 3) {
return { '#': String(idx), method: '?', callId: '?', summary: '' };
}
const [method, args, callId] = call as JmapCall;
return {
'#': String(idx),
method,
callId,
summary: args && typeof args === 'object' ? summarizeArgs(args) : '',
};
});
}
export function logJmapExchange(methodCalls: unknown, methodResponses: unknown, durationMs?: number): void {
if (!debugJmap) return;
const hasError = Array.isArray(methodResponses) && methodResponses.some((r) => Array.isArray(r) && r[0] === 'error');
const methodNames = Array.isArray(methodCalls)
? methodCalls.map((c) => (Array.isArray(c) && typeof c[0] === 'string' ? c[0] : '?')).join(' · ')
: '?';
const durationLabel = typeof durationMs === 'number' ? ` (${Math.round(durationMs)}ms)` : '';
const headerColor = hasError ? '#dc2626' : '#2563eb';
console.groupCollapsed(
`%c[JMAP]%c ${methodNames}${durationLabel}${hasError ? ' (with errors)' : ''}`,
`color: ${headerColor}; font-weight: bold`,
'color: inherit',
);
console.groupCollapsed('%c→ request', 'color: #2563eb; font-weight: bold');
console.table(buildSummaryRows(methodCalls));
console.log(JSON.stringify(methodCalls, null, 2));
console.groupEnd();
console.groupCollapsed(
`%c← response${hasError ? ' (with errors)' : ''}`,
`color: ${hasError ? '#dc2626' : '#16a34a'}; font-weight: bold`,
);
console.table(buildSummaryRows(methodResponses));
console.log(JSON.stringify(methodResponses, null, 2));
console.groupEnd();
console.groupEnd();
}
export function logFormChange(label: string, data: unknown): void {
if (!debugForms) return;
console.groupCollapsed(`%c[Form]%c ${label}`, 'color: #9333ea; font-weight: bold', 'color: inherit');
console.log(JSON.stringify(data, null, 2));
console.groupEnd();
}
+229
View File
@@ -0,0 +1,229 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect } from 'vitest';
import {
bytesToHuman,
humanToBytes,
formatSize,
msToHuman,
humanToMs,
formatDuration,
SIZE_UNITS,
DURATION_UNITS,
} from './durationFormat';
describe('SIZE_UNITS', () => {
it('should contain the expected units in order', () => {
expect(SIZE_UNITS).toEqual(['B', 'KB', 'MB', 'GB', 'TB']);
});
});
describe('DURATION_UNITS', () => {
it('should contain the expected units in order', () => {
expect(DURATION_UNITS).toEqual(['ms', 's', 'min', 'h', 'd']);
});
});
describe('bytesToHuman', () => {
it('should return 0 B for 0 bytes', () => {
expect(bytesToHuman(0)).toEqual({ value: 0, unit: 'B' });
});
it('should keep small values in bytes', () => {
expect(bytesToHuman(500)).toEqual({ value: 500, unit: 'B' });
});
it('should keep 512 bytes as B', () => {
expect(bytesToHuman(512)).toEqual({ value: 512, unit: 'B' });
});
it('should convert 1024 bytes to 1 KB', () => {
expect(bytesToHuman(1024)).toEqual({ value: 1, unit: 'KB' });
});
it('should convert 1536 bytes to 1.5 KB', () => {
expect(bytesToHuman(1536)).toEqual({ value: 1.5, unit: 'KB' });
});
it('should convert 1500 bytes to 1.46 KB', () => {
expect(bytesToHuman(1500)).toEqual({ value: 1.46, unit: 'KB' });
});
it('should convert 1048576 bytes to 1 MB', () => {
expect(bytesToHuman(1048576)).toEqual({ value: 1, unit: 'MB' });
});
it('should convert 10485760 bytes to 10 MB', () => {
expect(bytesToHuman(10485760)).toEqual({ value: 10, unit: 'MB' });
});
it('should convert 1073741824 bytes to 1 GB', () => {
expect(bytesToHuman(1073741824)).toEqual({ value: 1, unit: 'GB' });
});
it('should convert 1099511627776 bytes to 1 TB', () => {
expect(bytesToHuman(1099511627776)).toEqual({ value: 1, unit: 'TB' });
});
});
describe('humanToBytes', () => {
it('should convert 1 KB to 1024 bytes', () => {
expect(humanToBytes(1, 'KB')).toBe(1024);
});
it('should convert 10 MB to 10485760 bytes', () => {
expect(humanToBytes(10, 'MB')).toBe(10485760);
});
it('should convert 1.5 GB to 1610612736 bytes', () => {
expect(humanToBytes(1.5, 'GB')).toBe(1610612736);
});
it('should convert 0 B to 0', () => {
expect(humanToBytes(0, 'B')).toBe(0);
});
it('should return the raw value for an unknown unit', () => {
expect(humanToBytes(42, 'XYZ')).toBe(42);
});
});
describe('formatSize', () => {
it('should format 0 bytes as "0 B"', () => {
expect(formatSize(0)).toBe('0 B');
});
it('should format 1024 bytes as "1 KB"', () => {
expect(formatSize(1024)).toBe('1 KB');
});
it('should format 1572864 bytes as "1.5 MB"', () => {
expect(formatSize(1572864)).toBe('1.5 MB');
});
it('should format large values in TB', () => {
expect(formatSize(1099511627776)).toBe('1 TB');
});
});
describe('msToHuman', () => {
it('should return 0 ms for 0', () => {
expect(msToHuman(0)).toEqual({ value: 0, unit: 'ms' });
});
it('should keep 500 as ms', () => {
expect(msToHuman(500)).toEqual({ value: 500, unit: 'ms' });
});
it('should convert 1000 ms to 1 s', () => {
expect(msToHuman(1000)).toEqual({ value: 1, unit: 's' });
});
it('should convert 1500 ms to 1.5 s', () => {
expect(msToHuman(1500)).toEqual({ value: 1.5, unit: 's' });
});
it('should convert 60000 ms to 1 min', () => {
expect(msToHuman(60000)).toEqual({ value: 1, unit: 'min' });
});
it('should convert 300000 ms to 5 min', () => {
expect(msToHuman(300000)).toEqual({ value: 5, unit: 'min' });
});
it('should convert 90000 ms to 1.5 min', () => {
expect(msToHuman(90000)).toEqual({ value: 1.5, unit: 'min' });
});
it('should convert 3600000 ms to 1 h', () => {
expect(msToHuman(3600000)).toEqual({ value: 1, unit: 'h' });
});
it('should convert 86400000 ms to 1 d', () => {
expect(msToHuman(86400000)).toEqual({ value: 1, unit: 'd' });
});
});
describe('humanToMs', () => {
it('should convert 5 s to 5000 ms', () => {
expect(humanToMs(5, 's')).toBe(5000);
});
it('should convert 1 min to 60000 ms', () => {
expect(humanToMs(1, 'min')).toBe(60000);
});
it('should convert 2 h to 7200000 ms', () => {
expect(humanToMs(2, 'h')).toBe(7200000);
});
it('should convert 1 d to 86400000 ms', () => {
expect(humanToMs(1, 'd')).toBe(86400000);
});
it('should return the raw value for an unknown unit', () => {
expect(humanToMs(99, 'eons')).toBe(99);
});
});
describe('formatDuration', () => {
it('should format 0 as "0ms"', () => {
expect(formatDuration(0)).toBe('0ms');
});
it('should format 500 ms as "500ms"', () => {
expect(formatDuration(500)).toBe('500ms');
});
it('should format 5000 ms as "5s"', () => {
expect(formatDuration(5000)).toBe('5s');
});
it('should format 65000 ms as "1m 5s"', () => {
expect(formatDuration(65000)).toBe('1m 5s');
});
it('should format 3661000 ms as "1h 1m 1s"', () => {
expect(formatDuration(3661000)).toBe('1h 1m 1s');
});
it('should format 86400000 ms as "1d"', () => {
expect(formatDuration(86400000)).toBe('1d');
});
it('should format 90061500 ms as "1d 1h 1m 1s 500ms"', () => {
expect(formatDuration(90061500)).toBe('1d 1h 1m 1s 500ms');
});
});
describe('round-trip: bytes', () => {
it.each([1024, 1048576, 1073741824, 1099511627776])('bytesToHuman -> humanToBytes should recover %d', (original) => {
const { value, unit } = bytesToHuman(original);
expect(humanToBytes(value, unit)).toBe(original);
});
it('should be close for non-exact values', () => {
const original = 123456789;
const { value, unit } = bytesToHuman(original);
const recovered = humanToBytes(value, unit);
expect(Math.abs(recovered - original) / original).toBeLessThan(0.01);
});
});
describe('round-trip: duration', () => {
it.each([1000, 60000, 3600000, 86400000])('msToHuman -> humanToMs should recover %d', (original) => {
const { value, unit } = msToHuman(original);
expect(humanToMs(value, unit)).toBe(original);
});
it('should be close for non-exact values', () => {
const original = 123456;
const { value, unit } = msToHuman(original);
const recovered = humanToMs(value, unit);
expect(Math.abs(recovered - original) / original).toBeLessThan(0.01);
});
});
+96
View File
@@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const;
const SIZE_FACTORS: Record<string, number> = {
B: 1,
KB: 1024,
MB: 1024 ** 2,
GB: 1024 ** 3,
TB: 1024 ** 4,
};
export function bytesToHuman(bytes: number): { value: number; unit: string } {
if (bytes === 0) return { value: 0, unit: 'B' };
for (let i = SIZE_UNITS.length - 1; i >= 0; i--) {
const unit = SIZE_UNITS[i];
const factor = SIZE_FACTORS[unit];
const v = bytes / factor;
if (v >= 1) {
const rounded = Math.round(v * 100) / 100;
return { value: rounded, unit };
}
}
return { value: bytes, unit: 'B' };
}
export function humanToBytes(value: number, unit: string): number {
const factor = SIZE_FACTORS[unit];
if (factor === undefined) return value;
return Math.round(value * factor);
}
export function formatSize(bytes: number): string {
const { value, unit } = bytesToHuman(bytes);
return `${value} ${unit}`;
}
export const DURATION_UNITS = ['ms', 's', 'min', 'h', 'd'] as const;
const DURATION_FACTORS: Record<string, number> = {
ms: 1,
s: 1_000,
min: 60_000,
h: 3_600_000,
d: 86_400_000,
};
export function msToHuman(ms: number): { value: number; unit: string } {
if (ms === 0) return { value: 0, unit: 'ms' };
for (let i = DURATION_UNITS.length - 1; i >= 0; i--) {
const unit = DURATION_UNITS[i];
const factor = DURATION_FACTORS[unit];
const v = ms / factor;
if (v >= 1) {
const rounded = Math.round(v * 100) / 100;
return { value: rounded, unit };
}
}
return { value: ms, unit: 'ms' };
}
export function humanToMs(value: number, unit: string): number {
const factor = DURATION_FACTORS[unit];
if (factor === undefined) return value;
return Math.round(value * factor);
}
export function formatDuration(ms: number): string {
if (ms === 0) return '0ms';
const d = Math.floor(ms / 86_400_000);
ms %= 86_400_000;
const h = Math.floor(ms / 3_600_000);
ms %= 3_600_000;
const m = Math.floor(ms / 60_000);
ms %= 60_000;
const s = Math.floor(ms / 1_000);
ms %= 1_000;
const parts: string[] = [];
if (d > 0) parts.push(`${d}d`);
if (h > 0) parts.push(`${h}h`);
if (m > 0) parts.push(`${m}m`);
if (s > 0) parts.push(`${s}s`);
if (ms > 0) parts.push(`${ms}ms`);
return parts.join(' ');
}
+48
View File
@@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { JmapSetError } from '@/types/jmap';
import i18n from '@/i18n';
export function friendlySetError(err: JmapSetError): string {
if (err.description) return err.description;
switch (err.type) {
case 'forbidden':
return i18n.t('jmapErrors.forbidden', 'You do not have permission to perform this action.');
case 'notFound':
return i18n.t('jmapErrors.notFound', 'The item was not found. It may have been already deleted.');
case 'objectIsLinked':
return i18n.t('jmapErrors.objectIsLinked', 'This item is referenced by other items and cannot be deleted.');
case 'invalidForeignKey':
return i18n.t('jmapErrors.invalidForeignKey', 'This item references another item that does not exist.');
case 'primaryKeyViolation':
return i18n.t('jmapErrors.primaryKeyViolation', 'An item with the same key already exists.');
case 'alreadyExists':
return i18n.t('jmapErrors.alreadyExists', 'An item with the same identifier already exists.');
case 'overQuota':
return i18n.t('jmapErrors.overQuota', 'The operation exceeds your storage quota.');
case 'tooLarge':
return i18n.t('jmapErrors.tooLarge', 'The item is too large.');
case 'rateLimit':
return i18n.t('jmapErrors.rateLimit', 'Too many requests. Please try again later.');
case 'singleton':
return i18n.t('jmapErrors.singleton', 'This item is a singleton and cannot be deleted.');
case 'mailboxHasChild':
return i18n.t('jmapErrors.mailboxHasChild', 'This mailbox has child mailboxes. Delete them first.');
case 'mailboxHasEmail':
return i18n.t('jmapErrors.mailboxHasEmail', 'This mailbox contains messages. Remove them first.');
case 'calendarHasEvent':
return i18n.t('jmapErrors.calendarHasEvent', 'This calendar contains events. Remove them first.');
case 'addressBookHasContents':
return i18n.t('jmapErrors.addressBookHasContents', 'This address book has contacts. Remove them first.');
case 'nodeHasChildren':
return i18n.t('jmapErrors.nodeHasChildren', 'This item has children. Delete them first.');
case 'validationFailed':
return i18n.t('jmapErrors.validationFailed', 'The data did not pass validation.');
default:
return i18n.t('jmapErrors.unexpected', 'Unexpected error ({{type}}).', { type: err.type });
}
}
+513
View File
@@ -0,0 +1,513 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect } from 'vitest';
import { calculateJmapPatch, escapeJsonPointerToken } from './jmapPatch';
describe('calculateJmapPatch', () => {
it('detects changed primitives', () => {
const original = { name: 'Alice', age: 30, active: true };
const modified = { name: 'Bob', age: 31, active: false };
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Bob',
age: 31,
active: false,
});
});
it('detects added fields', () => {
const original = { name: 'Alice' };
const modified = { name: 'Alice', email: 'alice@example.com' };
expect(calculateJmapPatch(original, modified)).toEqual({
email: 'alice@example.com',
});
});
it('detects removed fields (set to null)', () => {
const original = { name: 'Alice', email: 'alice@example.com' };
const modified = { name: 'Alice' };
expect(calculateJmapPatch(original, modified)).toEqual({
email: null,
});
});
it('returns empty object when nothing changed', () => {
const original = { name: 'Alice', age: 30, active: true };
const modified = { name: 'Alice', age: 30, active: true };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('returns empty object for two empty objects', () => {
expect(calculateJmapPatch({}, {})).toEqual({});
});
it('produces JSON pointer paths for nested changes', () => {
const original = { address: { city: 'NYC', zip: '10001' } };
const modified = { address: { city: 'LA', zip: '10001' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'address/city': 'LA',
});
});
it('handles deeply nested changes', () => {
const original = { a: { b: { c: { d: 1 } } } };
const modified = { a: { b: { c: { d: 2 } } } };
expect(calculateJmapPatch(original, modified)).toEqual({
'a/b/c/d': 2,
});
});
it('detects added nested fields', () => {
const original = { address: { city: 'NYC' } };
const modified = { address: { city: 'NYC', zip: '10001' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'address/zip': '10001',
});
});
it('detects removed nested fields', () => {
const original = { address: { city: 'NYC', zip: '10001' } };
const modified = { address: { city: 'NYC' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'address/zip': null,
});
});
it('detects set additions', () => {
const original = { keywords: { important: true } };
const modified = { keywords: { important: true, urgent: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
'keywords/urgent': true,
});
});
it('detects set removals', () => {
const original = { keywords: { important: true, urgent: true } };
const modified = { keywords: { important: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
'keywords/urgent': null,
});
});
it('handles mixed set changes', () => {
const original = { keywords: { important: true, draft: true } };
const modified = { keywords: { important: true, urgent: true } };
const patch = calculateJmapPatch(original, modified);
expect(patch).toEqual({
'keywords/draft': null,
'keywords/urgent': true,
});
});
it('detects objectList item additions', () => {
const original = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
},
};
const modified = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
'1': { email: 'b@test.com', type: 'home' },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'emailAddresses/1': { email: 'b@test.com', type: 'home' },
});
});
it('detects objectList item removals', () => {
const original = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
'1': { email: 'b@test.com', type: 'home' },
},
};
const modified = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'emailAddresses/1': null,
});
});
it('detects objectList item field updates', () => {
const original = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'work' },
},
};
const modified = {
emailAddresses: {
'0': { email: 'a@test.com', type: 'home' },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'emailAddresses/0/type': 'home',
});
});
it('detects map key additions', () => {
const original = { mailboxIds: { inbox1: true } };
const modified = { mailboxIds: { inbox1: true, sent1: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
'mailboxIds/sent1': true,
});
});
it('detects map key removals', () => {
const original = { mailboxIds: { inbox1: true, sent1: true } };
const modified = { mailboxIds: { inbox1: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
'mailboxIds/sent1': null,
});
});
it('detects map value changes', () => {
const original = { settings: { theme: 'dark' } };
const modified = { settings: { theme: 'light' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'settings/theme': 'light',
});
});
it('handles map with object values', () => {
const original = {
accounts: {
acc1: { name: 'Account 1', isActive: true },
},
};
const modified = {
accounts: {
acc1: { name: 'Account 1', isActive: false },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'accounts/acc1/isActive': false,
});
});
it('handles map with nested object value additions', () => {
const original = { accounts: {} };
const modified = {
accounts: {
acc1: { name: 'Account 1', isActive: true },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'accounts/acc1': { name: 'Account 1', isActive: true },
});
});
it('includes @type in patch', () => {
const original = { '@type': 'Mailbox', name: 'Inbox' };
const modified = { '@type': 'MailboxChanged', name: 'Inbox' };
expect(calculateJmapPatch(original, modified)).toEqual({
'@type': 'MailboxChanged',
});
});
it('does not include @type when unchanged', () => {
const original = { '@type': 'Mailbox', name: 'Inbox' };
const modified = { '@type': 'Mailbox', name: 'Sent' };
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Sent',
});
});
it('does not null out fields from the previous variant on top-level @type change', () => {
const original = {
'@type': 'MaxMind',
asnUrls: ['https://example.com/asn.csv'],
expires: 86400,
geoUrls: ['https://example.com/geo.csv'],
httpAuth: null,
httpHeaders: {},
maxSize: 104857600,
timeout: 30000,
};
const modified = { '@type': 'Disabled' };
expect(calculateJmapPatch(original, modified)).toEqual({
'@type': 'Disabled',
});
});
it("includes the new variant's defaults on top-level @type change", () => {
const original = {
'@type': 'VariantA',
onlyOnA: 'x',
shared: 1,
};
const modified = {
'@type': 'VariantB',
onlyOnB: 'y',
shared: 2,
};
expect(calculateJmapPatch(original, modified)).toEqual({
'@type': 'VariantB',
onlyOnB: 'y',
shared: 2,
});
});
it('replaces nested object atomically when its @type changes', () => {
const original = {
certificateManagement: { '@type': 'Manual' },
};
const modified = {
certificateManagement: {
'@type': 'Automatic',
acmeProviderId: 'letsencrypt',
subjectAlternativeNames: { example: true },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
certificateManagement: {
'@type': 'Automatic',
acmeProviderId: 'letsencrypt',
subjectAlternativeNames: { example: true },
},
});
});
it('does not produce nested/@type patch entries on variant change', () => {
const original = {
dnsManagement: { '@type': 'Manual' },
};
const modified = {
dnsManagement: {
'@type': 'Automatic',
publishRecords: { dkim: true, mx: true },
},
};
const patch = calculateJmapPatch(original, modified);
expect(Object.keys(patch)).toEqual(['dnsManagement']);
expect(patch['dnsManagement/@type']).toBeUndefined();
expect(patch['dnsManagement/publishRecords']).toBeUndefined();
});
it('patches nested object properties normally when @type is unchanged', () => {
const original = {
dnsManagement: {
'@type': 'Automatic',
publishRecords: { dkim: true, mx: false },
},
};
const modified = {
dnsManagement: {
'@type': 'Automatic',
publishRecords: { dkim: true, mx: true },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'dnsManagement/publishRecords/mx': true,
});
});
it('replaces atomically when @type appears in modified but not original', () => {
const original = {
certificateManagement: {},
};
const modified = {
certificateManagement: { '@type': 'Automatic', acmeProviderId: 'le' },
};
expect(calculateJmapPatch(original, modified)).toEqual({
certificateManagement: { '@type': 'Automatic', acmeProviderId: 'le' },
});
});
it('replaces atomically when @type appears in original but not modified', () => {
const original = {
certificateManagement: { '@type': 'Manual' },
};
const modified = {
certificateManagement: {},
};
expect(calculateJmapPatch(original, modified)).toEqual({
certificateManagement: {},
});
});
it('handles deeply nested variant change (inside another nested object)', () => {
const original = {
model: {
outer: 'constant',
inner: { '@type': 'TypeA', a: 1 },
},
};
const modified = {
model: {
outer: 'constant',
inner: { '@type': 'TypeB', b: 2 },
},
};
expect(calculateJmapPatch(original, modified)).toEqual({
'model/inner': { '@type': 'TypeB', b: 2 },
});
});
it('only replaces the variant-changed sub-object, not siblings', () => {
const original = {
a: { '@type': 'X' },
b: { foo: 'bar' },
c: 1,
};
const modified = {
a: { '@type': 'Y', value: 42 },
b: { foo: 'baz' },
c: 2,
};
expect(calculateJmapPatch(original, modified)).toEqual({
a: { '@type': 'Y', value: 42 },
'b/foo': 'baz',
c: 2,
});
});
it('ignores id field', () => {
const original = { id: '123', name: 'Alice' };
const modified = { id: '456', name: 'Bob' };
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Bob',
});
});
it('ignores id field even when added', () => {
const original = { name: 'Alice' };
const modified = { id: '123', name: 'Alice' };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('ignores id field even when removed', () => {
const original = { id: '123', name: 'Alice' };
const modified = { name: 'Alice' };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('handles null to value', () => {
const original = { name: null };
const modified = { name: 'Alice' };
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Alice',
});
});
it('handles value to null', () => {
const original = { name: 'Alice' };
const modified = { name: null };
expect(calculateJmapPatch(original, modified)).toEqual({
name: null,
});
});
it('treats arrays as atomic', () => {
const original = { tags: ['a', 'b'] };
const modified = { tags: ['a', 'c'] };
expect(calculateJmapPatch(original, modified)).toEqual({
tags: ['a', 'c'],
});
});
it('returns empty for identical arrays', () => {
const original = { tags: ['a', 'b'] };
const modified = { tags: ['a', 'b'] };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('handles empty objects', () => {
const original = { data: {} };
const modified = { data: {} };
expect(calculateJmapPatch(original, modified)).toEqual({});
});
it('handles transition from primitive to object', () => {
const original = { field: 'string' };
const modified = { field: { nested: true } };
expect(calculateJmapPatch(original, modified)).toEqual({
field: { nested: true },
});
});
it('handles transition from object to primitive', () => {
const original = { field: { nested: true } };
const modified = { field: 'string' };
expect(calculateJmapPatch(original, modified)).toEqual({
field: 'string',
});
});
it('handles transition from array to object', () => {
const original = { field: [1, 2, 3] };
const modified = { field: { key: 'value' } };
expect(calculateJmapPatch(original, modified)).toEqual({
field: { key: 'value' },
});
});
it('handles transition from object to array', () => {
const original = { field: { key: 'value' } };
const modified = { field: [1, 2, 3] };
expect(calculateJmapPatch(original, modified)).toEqual({
field: [1, 2, 3],
});
});
describe('escapeJsonPointerToken', () => {
it('escapes tilde', () => {
expect(escapeJsonPointerToken('a~b')).toBe('a~0b');
});
it('escapes slash', () => {
expect(escapeJsonPointerToken('a/b')).toBe('a~1b');
});
it('escapes both, tilde first', () => {
expect(escapeJsonPointerToken('a~/b')).toBe('a~0~1b');
});
it('leaves unrelated characters alone', () => {
expect(escapeJsonPointerToken('plain')).toBe('plain');
});
});
it('escapes user-supplied map keys containing slashes in patches', () => {
const original = { headers: { 'X-Plain': 'old' } };
const modified = { headers: { 'X-Plain': 'old', 'X/Slashed': 'value' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'headers/X~1Slashed': 'value',
});
});
it('escapes user-supplied map keys containing tildes in patches', () => {
const original = { headers: {} };
const modified = { headers: { 'with~tilde': 'yes' } };
expect(calculateJmapPatch(original, modified)).toEqual({
'headers/with~0tilde': 'yes',
});
});
it('handles multiple changes at different nesting levels', () => {
const original = {
name: 'Test',
nested: {
a: 1,
b: { c: 2, d: 3 },
},
other: 'unchanged',
};
const modified = {
name: 'Updated',
nested: {
a: 1,
b: { c: 99, d: 3 },
},
other: 'unchanged',
};
expect(calculateJmapPatch(original, modified)).toEqual({
name: 'Updated',
'nested/b/c': 99,
});
});
});
+91
View File
@@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export function calculateJmapPatch(
original: Record<string, unknown>,
modified: Record<string, unknown>,
): Record<string, unknown> {
const patch: Record<string, unknown> = {};
const allKeys = new Set([...Object.keys(original), ...Object.keys(modified)]);
const variantChanged = '@type' in original && '@type' in modified && original['@type'] !== modified['@type'];
for (const key of allKeys) {
if (key === 'id') {
continue;
}
const origVal = original[key];
const modVal = modified[key];
if (!(key in original)) {
patch[key] = modVal;
continue;
}
if (!(key in modified)) {
if (variantChanged) continue;
patch[key] = null;
continue;
}
diffValues(key, origVal, modVal, patch);
}
return patch;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
export function escapeJsonPointerToken(key: string): string {
return key.replace(/~/g, '~0').replace(/\//g, '~1');
}
function diffValues(prefix: string, origVal: unknown, modVal: unknown, patch: Record<string, unknown>): void {
if (isPlainObject(origVal) && isPlainObject(modVal)) {
const origType = origVal['@type'];
const modType = modVal['@type'];
if (origType !== modType) {
patch[prefix] = modVal;
return;
}
const allSubKeys = new Set([...Object.keys(origVal), ...Object.keys(modVal)]);
for (const subKey of allSubKeys) {
const path = `${prefix}/${escapeJsonPointerToken(subKey)}`;
const origSub = origVal[subKey];
const modSub = modVal[subKey];
if (!(subKey in origVal)) {
patch[path] = modSub;
continue;
}
if (!(subKey in modVal)) {
patch[path] = null;
continue;
}
diffValues(path, origSub, modSub, patch);
}
return;
}
if (Array.isArray(origVal) && Array.isArray(modVal)) {
if (JSON.stringify(origVal) !== JSON.stringify(modVal)) {
patch[prefix] = modVal;
}
return;
}
if (origVal !== modVal) {
patch[prefix] = modVal;
}
}
+15
View File
@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export function jmapMapToArray<T>(val: unknown): T[] {
if (Array.isArray(val)) return val as T[];
if (val && typeof val === 'object') {
return Object.entries(val as Record<string, T>)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([, v]) => v);
}
return [];
}
+195
View File
@@ -0,0 +1,195 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Layout, LayoutItem, LayoutSubItem, Schema } from '@/types/schema';
import { resolveObject } from '@/lib/schemaResolver';
function findFirstSubLink(items: LayoutSubItem[]): string | null {
for (const item of items) {
if (item.type === 'link') return item.viewName;
if (item.type === 'container') {
const found = findFirstSubLink(item.items);
if (found) return found;
}
}
return null;
}
export function findFirstLinkInLayout(items: LayoutItem[] | Layout): string | null {
const list = Array.isArray(items) ? items : items.items;
for (const item of list) {
if ('link' in item) return item.link.viewName;
if ('container' in item) {
const found = findFirstSubLink(item.container.items);
if (found) return found;
}
}
return null;
}
export type CanGet = (permissionPrefix: string) => boolean;
export type HasPermission = (permission: string) => boolean;
interface SpecialLinkInfo {
visible: boolean;
enterprise: boolean;
}
function checkSpecialLink(
viewName: string,
edition: string,
hasPerm?: HasPermission,
canGet?: CanGet,
): SpecialLinkInfo | null {
if (viewName.startsWith('Dashboard/') || viewName === 'CustomComponent/Dashboard') {
if (edition === 'oss') return { visible: false, enterprise: true };
const hasLiveMetrics = hasPerm ? hasPerm('liveMetrics') : true;
const hasTraceGet = canGet ? canGet('sysTrace') : true;
return { visible: hasLiveMetrics && hasTraceGet, enterprise: true };
}
if (viewName === 'CustomComponent/LiveDelivery') {
const allowed = hasPerm ? hasPerm('liveDeliveryTest') : true;
return { visible: allowed, enterprise: false };
}
if (viewName === 'CustomComponent/LiveTracing') {
if (edition === 'oss') return { visible: false, enterprise: true };
const allowed = hasPerm ? hasPerm('liveTracing') : true;
return { visible: allowed, enterprise: true };
}
if (viewName.startsWith('CustomComponent/')) {
return { visible: true, enterprise: false };
}
return null;
}
export function isLinkVisible(
schema: Schema,
viewName: string,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): boolean {
const special = checkSpecialLink(viewName, edition, hasPerm, canGet);
if (special !== null) return special.visible;
const obj = schema.objects[viewName];
if (!obj) return false;
const resolved = resolveObject(schema, viewName);
if (!resolved) return false;
if (!canGet(resolved.permissionPrefix)) return false;
if (resolved.enterprise && edition === 'oss') return false;
return true;
}
export function isLinkEnterprise(schema: Schema, viewName: string, edition: string): boolean {
const special = checkSpecialLink(viewName, edition);
if (special !== null) return special.enterprise;
const obj = schema.objects[viewName];
if (!obj) return false;
if (obj.type === 'view') {
const parent = schema.objects[obj.objectName];
return parent?.type !== 'view' && parent?.enterprise === true;
}
return obj.enterprise === true;
}
function findFirstVisibleSubLink(
schema: Schema,
items: LayoutSubItem[],
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
for (const item of items) {
if (item.type === 'link') {
if (isLinkVisible(schema, item.viewName, edition, canGet, hasPerm)) return item.viewName;
} else if (item.type === 'container') {
const found = findFirstVisibleSubLink(schema, item.items, edition, canGet, hasPerm);
if (found) return found;
}
}
return null;
}
export function findFirstVisibleLinkInLayout(
schema: Schema,
layout: Layout,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
for (const item of layout.items) {
if ('link' in item) {
if (isLinkVisible(schema, item.link.viewName, edition, canGet, hasPerm)) return item.link.viewName;
} else if ('container' in item) {
const found = findFirstVisibleSubLink(schema, item.container.items, edition, canGet, hasPerm);
if (found) return found;
}
}
return null;
}
export function isLinkAccessible(
schema: Schema,
viewName: string,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): boolean {
if (!isLinkVisible(schema, viewName, edition, canGet, hasPerm)) return false;
if (edition === 'community' && isLinkEnterprise(schema, viewName, edition)) return false;
return true;
}
function findFirstAccessibleSubLink(
schema: Schema,
items: LayoutSubItem[],
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
for (const item of items) {
if (item.type === 'link') {
if (isLinkAccessible(schema, item.viewName, edition, canGet, hasPerm)) return item.viewName;
} else if (item.type === 'container') {
const found = findFirstAccessibleSubLink(schema, item.items, edition, canGet, hasPerm);
if (found) return found;
}
}
return null;
}
export function findFirstAccessibleLinkInLayout(
schema: Schema,
layout: Layout,
edition: string,
canGet: CanGet,
hasPerm?: HasPermission,
): string | null {
for (const item of layout.items) {
if ('link' in item) {
if (isLinkAccessible(schema, item.link.viewName, edition, canGet, hasPerm)) return item.link.viewName;
} else if ('container' in item) {
const found = findFirstAccessibleSubLink(schema, item.container.items, edition, canGet, hasPerm);
if (found) return found;
}
}
return null;
}
export function visibleLayouts(schema: Schema, edition: string, canGet: CanGet, hasPerm?: HasPermission): Layout[] {
return schema.layouts.filter(
(layout) => findFirstVisibleLinkInLayout(schema, layout, edition, canGet, hasPerm) !== null,
);
}
+161
View File
@@ -0,0 +1,161 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { resolveObject, resolveList, getDisplayProperty } from '@/lib/schemaResolver';
import { jmapGetBatched, jmapQueryAllAndGet, getAccountId } from '@/services/jmap/client';
import { useAccountStore } from '@/stores/accountStore';
import { useCacheStore, type ObjectListEntry } from '@/stores/cacheStore';
import type { Schema } from '@/types/schema';
interface PendingBatch {
ids: Set<string>;
displayProp: string;
schema: Schema;
}
const pendingByType = new Map<string, PendingBatch>();
function requestObjectLabel(parentObjectName: string, viewOrObjectName: string, id: string, schema: Schema): void {
const existing = pendingByType.get(parentObjectName);
if (existing) {
existing.ids.add(id);
return;
}
const batch: PendingBatch = {
ids: new Set([id]),
displayProp: getDisplayProperty(schema, viewOrObjectName),
schema,
};
pendingByType.set(parentObjectName, batch);
queueMicrotask(() => {
const taken = pendingByType.get(parentObjectName);
if (taken !== batch) return;
pendingByType.delete(parentObjectName);
void executeBatch(parentObjectName, taken);
});
}
async function executeBatch(parentObjectName: string, batch: PendingBatch): Promise<void> {
const ids = Array.from(batch.ids);
const setDisplayNames = useCacheStore.getState().setDisplayNames;
try {
const accountId = getAccountId(parentObjectName);
const list = await jmapGetBatched(parentObjectName, accountId, ids, ['id', batch.displayProp]);
const entries: Record<string, string> = {};
for (const item of list) {
const itemId = item.id as string;
if (!itemId) continue;
entries[itemId] = (item[batch.displayProp] as string) ?? itemId;
}
for (const id of ids) {
if (!(id in entries)) entries[id] = id;
}
setDisplayNames(parentObjectName, entries);
} catch (err) {
console.error('Failed to batch-fetch labels for', parentObjectName, err);
const fallback: Record<string, string> = {};
for (const id of ids) fallback[id] = id;
setDisplayNames(parentObjectName, fallback);
}
}
export type ObjectOption = ObjectListEntry;
async function fetchObjectList(viewOrObjectName: string, schema: Schema): Promise<ObjectOption[]> {
const resolved = resolveObject(schema, viewOrObjectName);
const objectName = resolved?.objectName ?? viewOrObjectName;
const list = resolveList(schema, viewOrObjectName, objectName);
const filtersStatic = list?.filtersStatic;
const accountId = getAccountId(objectName);
const displayProp = getDisplayProperty(schema, viewOrObjectName);
let items: Array<Record<string, unknown>> = [];
try {
const result = await jmapQueryAllAndGet(
objectName,
accountId,
{ filter: filtersStatic && Object.keys(filtersStatic).length > 0 ? filtersStatic : undefined },
['id', displayProp],
);
items = result.list;
} catch (err) {
console.error('JMAP error fetching', viewOrObjectName, err);
return [];
}
return items.map((item) => ({
id: item.id as string,
label: (item[displayProp] as string) ?? (item.id as string),
}));
}
export function useObjectList(viewOrObjectName: string, schema: Schema) {
const cached = useCacheStore((s) => s.objectLists[viewOrObjectName]);
const setObjectList = useCacheStore((s) => s.setObjectList);
const [loading, setLoading] = useState(false);
const ensureLoaded = useCallback(async () => {
if (cached) return;
setLoading(true);
try {
const opts = await fetchObjectList(viewOrObjectName, schema);
setObjectList(viewOrObjectName, opts);
} catch (err) {
console.error('Failed to fetch objects for', viewOrObjectName, err);
} finally {
setLoading(false);
}
}, [cached, viewOrObjectName, schema, setObjectList]);
return { options: cached ?? [], loading, hasLoaded: cached != null, ensureLoaded };
}
export function useObjectLabel(
viewOrObjectName: string,
id: string | null | undefined,
schema: Schema,
): { label: string | null; loading: boolean } {
const resolved = resolveObject(schema, viewOrObjectName);
const parentObjectName = resolved?.objectName ?? viewOrObjectName;
const cachedLabel = useCacheStore((s) => (id ? s.displayNames[parentObjectName]?.[id] : undefined));
useEffect(() => {
if (!id) return;
if (cachedLabel != null) return;
requestObjectLabel(parentObjectName, viewOrObjectName, id, schema);
}, [id, cachedLabel, parentObjectName, viewOrObjectName, schema]);
return {
label: cachedLabel ?? null,
loading: !!id && cachedLabel == null,
};
}
export function objectSupportsSearch(schema: Schema, objectName: string): boolean {
const resolved = resolveObject(schema, objectName);
if (!resolved) return false;
const list = resolveList(schema, objectName, resolved.objectName);
return list?.filters?.some((f) => f.type === 'text') ?? false;
}
export function useNoPermissionMessage(schema: Schema, viewOrObjectName: string): string | null {
const { t } = useTranslation();
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const resolved = resolveObject(schema, viewOrObjectName);
if (!resolved) return null;
const prefix = resolved.permissionPrefix;
if (hasObjectPermission(prefix, 'Get') && hasObjectPermission(prefix, 'Query')) return null;
const list = resolveList(schema, viewOrObjectName, resolved.objectName);
const name = list?.pluralName ?? resolved.objectName;
return t('field.noPermissionToView', 'You do not have permission to view {{name}}', { name });
}
+867
View File
@@ -0,0 +1,867 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect } from 'vitest';
import type { Schema } from '@/types/schema';
import {
resolveObject,
resolveSchema,
resolveList,
resolveForm,
resolveVariantForm,
deepMerge,
buildCreateDefaults,
buildEmbeddedDefaults,
} from './schemaResolver';
import { getDisplayProperty } from './schemaResolver';
const schema: Schema = {
objects: {
'x:Domain': {
type: 'object',
description: 'Mail domain',
permissionPrefix: 'sysDomain',
},
'x:Auth': {
type: 'singleton',
description: 'Authentication settings',
permissionPrefix: 'sysAuth',
},
'x:Account': {
type: 'object',
description: 'Account',
permissionPrefix: 'sysAccount',
},
'x:Account/User': {
type: 'view',
objectName: 'x:Account',
},
'x:Account/Group': {
type: 'view',
objectName: 'x:Account',
},
'x:Credential': {
type: 'object',
description: 'Credential',
permissionPrefix: 'sysCredential',
},
'x:Credential/ApiKey': {
type: 'view',
objectName: 'x:Credential',
},
'x:Tenant': {
type: 'object',
description: 'Tenant',
permissionPrefix: 'sysTenant',
enterprise: true,
},
'x:Bad/ViewOfView': {
type: 'view',
objectName: 'x:Account/User',
},
},
schemas: {
'x:Domain': {
type: 'single',
schemaName: 'DomainFields',
},
'x:Auth': {
type: 'single',
schemaName: 'AuthFields',
},
'x:Account': {
type: 'multiple',
variants: [
{ name: 'User', label: 'User Account', schemaName: 'UserFields' },
{ name: 'Group', label: 'Group Account', schemaName: 'GroupFields' },
{ name: 'External', label: 'External Account' },
],
},
'x:Credential': {
type: 'single',
schemaName: 'CredentialFields',
},
'x:Tenant': {
type: 'single',
schemaName: 'TenantFields',
},
'x:Orphan': {
type: 'single',
schemaName: 'MissingFields',
},
},
fields: {
DomainFields: {
properties: {
domainName: {
description: 'Domain name',
type: { type: 'string', format: 'string' },
update: 'immutable',
},
},
defaults: { domainName: 'example.com', active: true },
},
AuthFields: {
properties: {
method: {
description: 'Auth method',
type: { type: 'string', format: 'string' },
update: 'mutable',
},
},
defaults: { method: 'password' },
},
UserFields: {
properties: {
email: {
description: 'Email',
type: { type: 'string', format: 'emailAddress' },
update: 'mutable',
},
role: {
description: 'Role',
type: { type: 'string', format: 'string' },
update: 'mutable',
},
settings: {
description: 'Settings',
type: { type: 'object', objectName: 'x:UserSettings' },
update: 'mutable',
},
},
defaults: { email: '', quota: 1000, settings: { lang: 'en' } },
},
GroupFields: {
properties: {
groupName: {
description: 'Group name',
type: { type: 'string', format: 'string' },
update: 'mutable',
},
},
defaults: { groupName: '' },
},
CredentialFields: {
properties: {
token: {
description: 'Token',
type: { type: 'string', format: 'secret' },
update: 'serverSet',
},
},
defaults: { expiresIn: 3600 },
},
TenantFields: {
properties: {
tenantName: {
description: 'Tenant name',
type: { type: 'string', format: 'string' },
update: 'mutable',
},
},
},
AccountBaseFields: {
properties: {},
},
},
forms: {
'x:Domain': {
title: 'Domain Form',
sections: [{ fields: [{ name: 'domainName', label: 'Domain' }] }],
},
'x:Account/User': {
title: 'User Form',
sections: [{ fields: [{ name: 'email', label: 'Email' }] }],
},
'x:Account': {
title: 'Account Form',
sections: [{ fields: [{ name: 'name', label: 'Name' }] }],
},
UserFields: {
title: 'User Schema Form',
sections: [{ fields: [{ name: 'email', label: 'Email' }] }],
},
GroupFields: {
title: 'Group Schema Form',
sections: [{ fields: [{ name: 'name', label: 'Name' }] }],
},
CredentialFields: {
title: 'Credential Schema Form',
sections: [{ fields: [{ name: 'token', label: 'Token' }] }],
},
},
lists: {
'x:Domain': {
title: 'Domains',
subtitle: 'All domains',
labelProperty: 'domainName',
singularName: 'domain',
pluralName: 'domains',
columns: [
{ name: 'domainName', label: 'Domain' },
{ name: 'active', label: 'Active' },
],
},
'x:Account/User': {
title: 'Users',
subtitle: 'User accounts',
singularName: 'user',
pluralName: 'users',
columns: [{ name: 'email', label: 'Email' }],
filtersStatic: { role: 'user', settings: { theme: 'dark', notifications: true } },
},
'x:Account': {
title: 'Accounts',
subtitle: 'All accounts',
singularName: 'account',
pluralName: 'accounts',
columns: [{ name: 'name', label: 'Name' }],
},
'x:Credential': {
title: 'Credentials',
subtitle: 'All credentials',
singularName: 'credential',
pluralName: 'credentials',
columns: [{ name: 'token', label: 'Token' }],
},
'x:NoLabel': {
title: 'No Label',
subtitle: '',
singularName: 'item',
pluralName: 'items',
columns: [{ name: 'title', label: 'Title' }],
},
'x:Empty': {
title: 'Empty',
subtitle: '',
singularName: 'thing',
pluralName: 'things',
columns: [],
},
},
enums: {},
dashboards: [],
layouts: [],
};
describe('resolveObject', () => {
it('resolves a regular object', () => {
const result = resolveObject(schema, 'x:Domain');
expect(result).not.toBeNull();
expect(result!.viewName).toBe('x:Domain');
expect(result!.objectName).toBe('x:Domain');
expect(result!.objectType.type).toBe('object');
expect(result!.permissionPrefix).toBe('sysDomain');
expect(result!.enterprise).toBe(false);
});
it('resolves a singleton', () => {
const result = resolveObject(schema, 'x:Auth');
expect(result).not.toBeNull();
expect(result!.viewName).toBe('x:Auth');
expect(result!.objectName).toBe('x:Auth');
expect(result!.objectType.type).toBe('singleton');
expect(result!.permissionPrefix).toBe('sysAuth');
expect(result!.enterprise).toBe(false);
});
it('resolves a view pointing to a parent object', () => {
const result = resolveObject(schema, 'x:Account/User');
expect(result).not.toBeNull();
expect(result!.viewName).toBe('x:Account/User');
expect(result!.objectName).toBe('x:Account');
expect(result!.objectType.type).toBe('object');
expect(result!.permissionPrefix).toBe('sysAccount');
expect(result!.enterprise).toBe(false);
});
it('returns null for unknown viewName', () => {
expect(resolveObject(schema, 'x:NonExistent')).toBeNull();
});
it('returns true for enterprise flag', () => {
const result = resolveObject(schema, 'x:Tenant');
expect(result!.enterprise).toBe(true);
});
it('returns null when a view points to another view', () => {
const result = resolveObject(schema, 'x:Bad/ViewOfView');
expect(result).toBeNull();
});
it('returns null when a view points to a missing parent', () => {
const badSchema: Schema = {
...schema,
objects: {
'x:Dangling': { type: 'view', objectName: 'x:Gone' },
},
};
expect(resolveObject(badSchema, 'x:Dangling')).toBeNull();
});
});
describe('resolveSchema', () => {
it('resolves a single schema', () => {
const result = resolveSchema(schema, 'x:Domain')!;
expect(result).not.toBeNull();
expect(result.type).toBe('single');
if (result.type === 'single') {
expect(result.schemaName).toBe('DomainFields');
expect(result.fields.properties).toHaveProperty('domainName');
}
});
it('resolves a multiple schema with variants', () => {
const result = resolveSchema(schema, 'x:Account')!;
expect(result).not.toBeNull();
expect(result.type).toBe('multiple');
if (result.type === 'multiple') {
expect(result.variants).toHaveLength(3);
expect(result.variants[0].name).toBe('User');
expect(result.variants[0].label).toBe('User Account');
expect(result.variants[0].fields).not.toBeNull();
expect(result.variants[0].fields!.properties).toHaveProperty('email');
}
});
it('sets fields to null for variant without schemaName', () => {
const result = resolveSchema(schema, 'x:Account')!;
if (result.type === 'multiple') {
const ext = result.variants.find((v) => v.name === 'External');
expect(ext).toBeDefined();
expect(ext!.fields).toBeNull();
expect(ext!.schemaName).toBeUndefined();
}
});
it('returns null for unknown objectName', () => {
expect(resolveSchema(schema, 'x:Unknown')).toBeNull();
});
it('returns null when single schema references missing fields', () => {
expect(resolveSchema(schema, 'x:Orphan')).toBeNull();
});
it('returns fields with defaults for single schema', () => {
const result = resolveSchema(schema, 'x:Domain')!;
if (result.type === 'single') {
expect(result.fields.defaults).toEqual({ domainName: 'example.com', active: true });
}
});
it('preserves schemaName on variant entries', () => {
const result = resolveSchema(schema, 'x:Account')!;
if (result.type === 'multiple') {
expect(result.variants[0].schemaName).toBe('UserFields');
expect(result.variants[1].schemaName).toBe('GroupFields');
}
});
});
describe('resolveList', () => {
it('returns view-specific list when it exists', () => {
const list = resolveList(schema, 'x:Account/User', 'x:Account');
expect(list).not.toBeNull();
expect(list!.title).toBe('Users');
});
it('falls back to parent objectName list', () => {
const list = resolveList(schema, 'x:Account/Group', 'x:Account');
expect(list).not.toBeNull();
expect(list!.title).toBe('Accounts');
});
it('returns null when neither view nor object list exists', () => {
const list = resolveList(schema, 'x:NoView', 'x:NoObject');
expect(list).toBeNull();
});
it('returns the list for a direct object', () => {
const list = resolveList(schema, 'x:Domain', 'x:Domain');
expect(list).not.toBeNull();
expect(list!.title).toBe('Domains');
});
it('prefers viewName over objectName', () => {
const list = resolveList(schema, 'x:Account/User', 'x:Account');
expect(list!.singularName).toBe('user');
});
});
describe('resolveForm', () => {
it('returns view-specific form', () => {
const form = resolveForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('User Form');
});
it('falls back to objectName form', () => {
const form = resolveForm(schema, 'x:Account/Group', 'x:Account', 'GroupFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('Account Form');
});
it('falls back to schemaName form', () => {
const smallSchema: Schema = {
...schema,
forms: {
CredentialFields: schema.forms['CredentialFields'],
},
};
const form = resolveForm(smallSchema, 'x:Credential/ApiKey', 'x:Credential', 'CredentialFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('Credential Schema Form');
});
it('returns null when no form found at any level', () => {
const form = resolveForm(schema, 'x:NoView', 'x:NoObject', 'NoSchema');
expect(form).toBeNull();
});
it('uses viewName form even when objectName and schemaName also exist', () => {
const form = resolveForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
expect(form!.title).toBe('User Form');
});
it('returns domain form for direct object', () => {
const form = resolveForm(schema, 'x:Domain', 'x:Domain', 'DomainFields');
expect(form!.title).toBe('Domain Form');
});
});
describe('resolveVariantForm', () => {
it('returns the variant-specific form by schemaName', () => {
const form = resolveVariantForm(schema, 'x:Account/User', 'x:Account', 'UserFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('User Schema Form');
});
it('does not fall back to the parent form even if it exists', () => {
const form = resolveVariantForm(schema, 'x:Account/Group', 'x:Account', 'GroupFields');
expect(form).not.toBeNull();
expect(form!.title).toBe('Group Schema Form');
});
it('returns null when variantSchemaName is undefined', () => {
const form = resolveVariantForm(schema, 'x:NoView', 'x:NoObject');
expect(form).toBeNull();
});
it('returns null when variantSchemaName is provided but has no form', () => {
const form = resolveVariantForm(schema, 'x:NoView', 'x:NoObject', 'NonExistentSchema');
expect(form).toBeNull();
});
});
describe('deepMerge', () => {
it('merges flat objects', () => {
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
});
it('source overrides target for same key', () => {
expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
});
it('deeply merges nested objects', () => {
const target = { x: { a: 1, b: 2 } };
const source = { x: { b: 3, c: 4 } };
expect(deepMerge(target, source)).toEqual({ x: { a: 1, b: 3, c: 4 } });
});
it('replaces arrays instead of merging them', () => {
const target = { arr: [1, 2, 3] };
const source = { arr: [4, 5] };
expect(deepMerge(target, source)).toEqual({ arr: [4, 5] });
});
it('handles null source values (replaces target)', () => {
const target: Record<string, unknown> = { a: { nested: 1 } };
const source: Record<string, unknown> = { a: null };
expect(deepMerge(target, source)).toEqual({ a: null });
});
it('handles null target values (replaced by source object)', () => {
const target: Record<string, unknown> = { a: null };
const source: Record<string, unknown> = { a: { nested: 1 } };
expect(deepMerge(target, source)).toEqual({ a: { nested: 1 } });
});
it('handles empty source object', () => {
expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 });
});
it('handles empty target object', () => {
expect(deepMerge({}, { a: 1 })).toEqual({ a: 1 });
});
it('handles both empty', () => {
expect(deepMerge({}, {})).toEqual({});
});
it('deeply merges 3 levels', () => {
const target = { l1: { l2: { l3: 'original', keep: true } } };
const source = { l1: { l2: { l3: 'updated', added: 42 } } };
expect(deepMerge(target, source)).toEqual({
l1: { l2: { l3: 'updated', keep: true, added: 42 } },
});
});
it('does not mutate target', () => {
const target = { a: 1, nested: { b: 2 } };
const original = { ...target, nested: { ...target.nested } };
deepMerge(target, { a: 99, nested: { c: 3 } });
expect(target).toEqual(original);
});
it('does not mutate source', () => {
const source = { a: 1 };
const copy = { ...source };
deepMerge({}, source);
expect(source).toEqual(copy);
});
it('replaces string with object', () => {
const target: Record<string, unknown> = { a: 'hello' };
const source: Record<string, unknown> = { a: { nested: 1 } };
expect(deepMerge(target, source)).toEqual({ a: { nested: 1 } });
});
it('replaces object with string', () => {
const target: Record<string, unknown> = { a: { nested: 1 } };
const source: Record<string, unknown> = { a: 'hello' };
expect(deepMerge(target, source)).toEqual({ a: 'hello' });
});
});
const embeddedSchema: Schema = {
objects: {
'x:SpamClassifier': {
type: 'singleton',
description: 'Spam classifier',
permissionPrefix: 'sysSpam',
},
},
schemas: {
'x:Model': {
type: 'multiple',
variants: [
{ name: 'FtrlFh', label: 'FTRL FH', schemaName: 'x:FtrlFh' },
{ name: 'FtrlCcfh', label: 'FTRL CCFH', schemaName: 'x:FtrlCcfh' },
],
},
'x:FtrlParameters': {
type: 'single',
schemaName: 'x:FtrlParameters',
},
'x:CertManagement': {
type: 'multiple',
variants: [
{ name: 'Manual', label: 'Manual' },
{ name: 'Automatic', label: 'Automatic', schemaName: 'x:CertAuto' },
],
},
},
fields: {
'x:FtrlFh': {
properties: {
learningRate: {
description: '',
type: { type: 'number', format: 'float' },
update: 'mutable',
},
},
defaults: {
learningRate: 0.01,
},
},
'x:FtrlCcfh': {
properties: {
featureL2Normalize: {
description: '',
type: { type: 'boolean' },
update: 'mutable',
},
parameters: {
description: '',
type: { type: 'object', objectName: 'x:FtrlParameters' },
update: 'mutable',
},
indicatorParameters: {
description: '',
type: { type: 'object', objectName: 'x:FtrlParameters' },
update: 'mutable',
},
},
defaults: {
featureL2Normalize: true,
parameters: { numFeatures: '20' },
indicatorParameters: { numFeatures: '18' },
},
},
'x:FtrlParameters': {
properties: {
alpha: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
beta: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
l1Ratio: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
l2Ratio: { description: '', type: { type: 'number', format: 'float' }, update: 'mutable' },
numFeatures: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
},
defaults: {
alpha: 2,
beta: 1,
l1Ratio: 0.001,
l2Ratio: 0.0001,
numFeatures: '50',
},
},
'x:CertAuto': {
properties: {
acmeProviderId: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
},
defaults: {},
},
},
forms: {},
lists: {},
enums: {},
dashboards: [],
layouts: [],
};
describe('buildEmbeddedDefaults', () => {
it('returns child schema defaults for a single object', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:FtrlParameters');
expect(result).toEqual({
alpha: 2,
beta: 1,
l1Ratio: 0.001,
l2Ratio: 0.0001,
numFeatures: '50',
});
});
it('returns variant defaults with @type for a multi-variant object', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlFh');
expect(result).toEqual({
'@type': 'FtrlFh',
learningRate: 0.01,
});
});
it('recursively merges parent overrides into child defaults for embedded fields', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
expect(result).toEqual({
'@type': 'FtrlCcfh',
featureL2Normalize: true,
parameters: {
alpha: 2,
beta: 1,
l1Ratio: 0.001,
l2Ratio: 0.0001,
numFeatures: '20',
},
indicatorParameters: {
alpha: 2,
beta: 1,
l1Ratio: 0.001,
l2Ratio: 0.0001,
numFeatures: '18',
},
});
});
it('applies caller-supplied parent overrides on top of everything', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', { featureL2Normalize: false }, 'FtrlCcfh');
expect(result.featureL2Normalize).toBe(false);
expect(result.parameters).toMatchObject({ alpha: 2, numFeatures: '20' });
});
it('handles a variant with no schemaName', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:CertManagement', {}, 'Manual');
expect(result).toEqual({ '@type': 'Manual' });
});
it('handles a variant with empty defaults', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:CertManagement', {}, 'Automatic');
expect(result).toEqual({ '@type': 'Automatic' });
});
it('returns parentOverrides when objectName is not in schema', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Unknown', { foo: 'bar' });
expect(result).toEqual({ foo: 'bar' });
});
it('falls back to first variant when variantName is not provided', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model');
expect(result['@type']).toBe('FtrlFh');
expect(result.learningRate).toBe(0.01);
});
it('parent override only mentions some keys -- others come from child', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
const params = result.parameters as Record<string, unknown>;
expect(Object.keys(params).sort()).toEqual(['alpha', 'beta', 'l1Ratio', 'l2Ratio', 'numFeatures']);
});
it('parent overrides take precedence for matching nested keys', () => {
const result = buildEmbeddedDefaults(embeddedSchema, 'x:Model', {}, 'FtrlCcfh');
expect((result.parameters as Record<string, unknown>).numFeatures).toBe('20');
expect((result.indicatorParameters as Record<string, unknown>).numFeatures).toBe('18');
});
});
describe('buildCreateDefaults', () => {
it('returns single schema defaults', () => {
const ro = resolveObject(schema, 'x:Domain')!;
const rs = resolveSchema(schema, 'x:Domain')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).toEqual({ domainName: 'example.com', active: true });
});
it('returns multi-variant defaults with @type', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const defaults = buildCreateDefaults(schema, ro, rs, 'User');
expect(defaults['@type']).toBe('User');
});
it('includes variant field defaults for multi-variant', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const defaults = buildCreateDefaults(schema, ro, rs, 'User');
expect(defaults).toHaveProperty('email', '');
expect(defaults).toHaveProperty('quota', 1000);
});
it('sets @type even when variant has no fields defaults', () => {
const ro = resolveObject(schema, 'x:Account/Group')!;
const rs = resolveSchema(schema, 'x:Account')!;
const defaults = buildCreateDefaults(schema, ro, rs, 'External');
expect(defaults['@type']).toBe('External');
});
it('applies parent schema defaults on top of variant defaults', () => {
const ro = resolveObject(schema, 'x:Credential/ApiKey')!;
const rs = resolveSchema(schema, 'x:Credential')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).toHaveProperty('expiresIn', 3600);
});
it('applies filtersStatic field values on top of everything', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const list = resolveList(schema, 'x:Account/User', 'x:Account');
const defaults = buildCreateDefaults(schema, ro, rs, 'User', list?.filtersStatic);
expect(defaults).toHaveProperty('role', 'user');
});
it('deep-merges nested filtersStatic values with variant defaults', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const list = resolveList(schema, 'x:Account/User', 'x:Account');
const defaults = buildCreateDefaults(schema, ro, rs, 'User', list?.filtersStatic);
expect(defaults.settings).toEqual({
lang: 'en',
theme: 'dark',
notifications: true,
});
});
it('returns empty object when schema has no defaults', () => {
const ro = resolveObject(schema, 'x:Tenant')!;
const rs = resolveSchema(schema, 'x:Tenant')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).toEqual({});
});
it('does not set @type for single schema', () => {
const ro = resolveObject(schema, 'x:Domain')!;
const rs = resolveSchema(schema, 'x:Domain')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).not.toHaveProperty('@type');
});
it('does not set @type for multiple schema when variantName is not provided', () => {
const ro = resolveObject(schema, 'x:Account/User')!;
const rs = resolveSchema(schema, 'x:Account')!;
const defaults = buildCreateDefaults(schema, ro, rs);
expect(defaults).not.toHaveProperty('@type');
});
it('merge order: child defaults < parent defaults < filtersStatic', () => {
const custom: Schema = {
...schema,
objects: {
'x:Parent': {
type: 'object',
description: 'Parent',
permissionPrefix: 'p',
},
'x:Parent/Child': {
type: 'view',
objectName: 'x:Parent',
},
},
schemas: {
'x:Parent': {
type: 'single',
schemaName: 'ParentFields',
},
},
fields: {
ParentFields: {
properties: {
priority: { description: '', type: { type: 'string', format: 'string' }, update: 'mutable' },
fromParent: { description: '', type: { type: 'boolean' }, update: 'mutable' },
},
defaults: { priority: 'parent', fromParent: true },
},
},
lists: {
'x:Parent/Child': {
title: 'Children',
subtitle: '',
singularName: 'child',
pluralName: 'children',
columns: [],
filtersStatic: { priority: 'filter' },
},
},
};
const ro = resolveObject(custom, 'x:Parent/Child')!;
const rs = resolveSchema(custom, 'x:Parent')!;
const defaults = buildCreateDefaults(custom, ro, rs, undefined, custom.lists['x:Parent/Child'].filtersStatic);
expect(defaults.priority).toBe('filter');
expect(defaults.fromParent).toBe(true);
});
});
describe('getDisplayProperty', () => {
it('returns labelProperty when defined', () => {
expect(getDisplayProperty(schema, 'x:Domain')).toBe('domainName');
});
it('returns first column name when no labelProperty', () => {
expect(getDisplayProperty(schema, 'x:Account/User')).toBe('email');
});
it('falls back to "name" when no list exists', () => {
expect(getDisplayProperty(schema, 'x:NonExistent')).toBe('name');
});
it('falls back to "name" when columns are empty', () => {
expect(getDisplayProperty(schema, 'x:Empty')).toBe('name');
});
it('returns first column when labelProperty is absent but columns exist', () => {
expect(getDisplayProperty(schema, 'x:NoLabel')).toBe('title');
});
});
+268
View File
@@ -0,0 +1,268 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Schema, ObjectTypeObject, ObjectTypeSingleton, Fields, Form, List } from '@/types/schema';
export interface ResolvedObject {
viewName: string;
objectName: string;
objectType: ObjectTypeObject | ObjectTypeSingleton;
permissionPrefix: string;
enterprise: boolean;
}
export function resolveObject(schema: Schema, viewName: string): ResolvedObject | null {
const entry = schema.objects[viewName];
if (!entry) return null;
if (entry.type === 'view') {
const parent = schema.objects[entry.objectName];
if (!parent || parent.type === 'view') return null;
return {
viewName,
objectName: entry.objectName,
objectType: parent,
permissionPrefix: parent.permissionPrefix,
enterprise: parent.enterprise ?? false,
};
}
return {
viewName,
objectName: viewName,
objectType: entry,
permissionPrefix: entry.permissionPrefix,
enterprise: entry.enterprise ?? false,
};
}
export interface ResolvedSingleSchema {
type: 'single';
schemaName: string;
fields: Fields;
}
export interface ResolvedMultipleSchema {
type: 'multiple';
variants: {
name: string;
label: string;
schemaName?: string;
fields: Fields | null;
}[];
}
export type ResolvedSchema = ResolvedSingleSchema | ResolvedMultipleSchema;
export function resolveSchema(schema: Schema, objectName: string): ResolvedSchema | null {
const schemaEntry = schema.schemas[objectName];
if (!schemaEntry) return null;
if (schemaEntry.type === 'single') {
const fields = schema.fields[schemaEntry.schemaName];
if (!fields) return null;
return { type: 'single', schemaName: schemaEntry.schemaName, fields };
}
return {
type: 'multiple',
variants: schemaEntry.variants.map((v) => ({
name: v.name,
label: v.label,
schemaName: v.schemaName,
fields: v.schemaName ? (schema.fields[v.schemaName] ?? null) : null,
})),
};
}
export function resolveList(schema: Schema, viewName: string, objectName: string): List | null {
return schema.lists[viewName] ?? schema.lists[objectName] ?? null;
}
export function resolveForm(schema: Schema, viewName: string, objectName: string, schemaName: string): Form | null {
return schema.forms[viewName] ?? schema.forms[objectName] ?? schema.forms[schemaName] ?? null;
}
export function resolveVariantForm(
schema: Schema,
_viewName: string,
_objectName: string,
variantSchemaName?: string,
): Form | null {
if (variantSchemaName && schema.forms[variantSchemaName]) {
return schema.forms[variantSchemaName];
}
return null;
}
export function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
const result = { ...target };
for (const key of Object.keys(source)) {
const sourceVal = source[key];
const targetVal = result[key];
if (
sourceVal &&
typeof sourceVal === 'object' &&
!Array.isArray(sourceVal) &&
targetVal &&
typeof targetVal === 'object' &&
!Array.isArray(targetVal)
) {
result[key] = deepMerge(targetVal as Record<string, unknown>, sourceVal as Record<string, unknown>);
} else {
result[key] = sourceVal;
}
}
return result;
}
export function buildCreateDefaults(
schema: Schema,
resolvedObject: ResolvedObject,
resolvedSchema: ResolvedSchema,
variantName?: string,
filtersStatic?: Record<string, unknown>,
): Record<string, unknown> {
let defaults: Record<string, unknown> = {};
let activeFields: Fields | null = null;
if (resolvedSchema.type === 'single') {
activeFields = resolvedSchema.fields;
const childDefaults = resolvedSchema.fields.defaults ?? {};
defaults = deepMerge(defaults, childDefaults);
} else if (resolvedSchema.type === 'multiple' && variantName) {
const variant = resolvedSchema.variants.find((v) => v.name === variantName);
activeFields = variant?.fields ?? null;
if (variant?.fields?.defaults) {
defaults = deepMerge(defaults, variant.fields.defaults);
}
defaults['@type'] = variantName;
}
const parentSchemaEntry = schema.schemas[resolvedObject.objectName];
if (parentSchemaEntry?.type === 'single') {
const parentFields = schema.fields[parentSchemaEntry.schemaName];
if (parentFields?.defaults) {
defaults = deepMerge(defaults, parentFields.defaults);
}
}
if (activeFields) {
for (const [propName, propDef] of Object.entries(activeFields.properties)) {
const t = propDef.type;
if (t.type !== 'object') continue;
if (defaults[propName] === null) continue;
const parentChildOverride =
defaults[propName] && typeof defaults[propName] === 'object' && !Array.isArray(defaults[propName])
? (defaults[propName] as Record<string, unknown>)
: {};
const nestedSchemaEntry = schema.schemas[t.objectName];
let nestedVariant: string | undefined;
if (nestedSchemaEntry?.type === 'multiple') {
nestedVariant = (parentChildOverride['@type'] as string | undefined) ?? nestedSchemaEntry.variants[0]?.name;
}
const nested = buildEmbeddedDefaults(schema, t.objectName, parentChildOverride, nestedVariant);
if (Object.keys(nested).length > 0) {
defaults[propName] = nested;
}
}
}
if (filtersStatic && Object.keys(filtersStatic).length > 0) {
const validKeys = new Set<string>(['@type']);
if (activeFields) {
for (const k of Object.keys(activeFields.properties)) validKeys.add(k);
}
if (resolvedSchema.type === 'multiple') {
const parentSchemaName =
parentSchemaEntry?.type === 'single' ? parentSchemaEntry.schemaName : resolvedObject.objectName;
const parentProps = schema.fields[parentSchemaName]?.properties;
if (parentProps) {
for (const k of Object.keys(parentProps)) validKeys.add(k);
}
}
const filtered: Record<string, unknown> = {};
for (const [k, v] of Object.entries(filtersStatic)) {
if (validKeys.has(k)) filtered[k] = v;
}
if (Object.keys(filtered).length > 0) {
defaults = deepMerge(defaults, filtered);
}
}
return defaults;
}
export function buildEmbeddedDefaults(
schema: Schema,
objectName: string,
parentOverrides: Record<string, unknown> = {},
variantName?: string,
): Record<string, unknown> {
const schemaEntry = schema.schemas[objectName];
if (!schemaEntry) return { ...parentOverrides };
let fields: Fields | null = null;
let result: Record<string, unknown> = {};
if (schemaEntry.type === 'single') {
fields = schema.fields[schemaEntry.schemaName] ?? null;
} else {
const variant = variantName ? schemaEntry.variants.find((v) => v.name === variantName) : schemaEntry.variants[0];
if (!variant) return { '@type': variantName ?? '', ...parentOverrides };
result['@type'] = variant.name;
if (variant.schemaName) {
fields = schema.fields[variant.schemaName] ?? null;
}
}
if (fields) {
if (fields.defaults) {
result = deepMerge(result, fields.defaults);
}
for (const [propName, propDef] of Object.entries(fields.properties)) {
const t = propDef.type;
if (t.type !== 'object') continue;
const parentChildOverride =
result[propName] && typeof result[propName] === 'object' && !Array.isArray(result[propName])
? (result[propName] as Record<string, unknown>)
: {};
const nestedSchemaEntry = schema.schemas[t.objectName];
let nestedVariant: string | undefined;
if (nestedSchemaEntry?.type === 'multiple') {
nestedVariant = (parentChildOverride['@type'] as string | undefined) ?? nestedSchemaEntry.variants[0]?.name;
}
const nestedDefaults = buildEmbeddedDefaults(schema, t.objectName, parentChildOverride, nestedVariant);
if (Object.keys(nestedDefaults).length > 0) {
result[propName] = nestedDefaults;
}
}
}
if (Object.keys(parentOverrides).length > 0) {
result = deepMerge(result, parentOverrides);
}
return result;
}
export function getDisplayProperty(schema: Schema, objectName: string): string {
const list = schema.lists[objectName];
if (list?.labelProperty) return list.labelProperty;
if (list?.columns?.[0]) return list.columns[0].name;
return 'name';
}
+12
View File
@@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+74
View File
@@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import './i18n';
import './index.css';
import App from './App';
import LoginPage from './pages/LoginPage';
import OAuthCallback from './pages/OAuthCallback';
import AdminPanel from './pages/AdminPanel';
import NotFound from './pages/NotFound';
import { ProtectedRoute } from './components/layout/ProtectedRoute';
import { getBasePath } from './lib/basePath';
(() => {
try {
const persisted = localStorage.getItem('stalwart-ui');
if (persisted) {
const parsed = JSON.parse(persisted);
const theme = parsed?.state?.theme;
if (theme === 'dark' || theme === 'light') {
document.documentElement.classList.toggle('dark', theme === 'dark');
return;
}
}
// eslint-disable-next-line no-empty
} catch {}
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', !!prefersDark);
})();
const basePath = getBasePath();
const router = createBrowserRouter(
[
{
path: '/',
element: <App />,
errorElement: <NotFound />,
children: [
{ path: 'login', element: <LoginPage /> },
{ path: 'oauth/callback', element: <OAuthCallback /> },
{
path: ':section/*',
element: (
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
),
},
{
index: true,
element: (
<ProtectedRoute>
<AdminPanel />
</ProtectedRoute>
),
},
],
},
],
{ basename: basePath },
);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
+260
View File
@@ -0,0 +1,260 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/stores/authStore';
import { useSchemaStore } from '@/stores/schemaStore';
import { useAccountStore } from '@/stores/accountStore';
import { useUIStore } from '@/stores/uiStore';
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
import { setLocale } from '@/i18n';
import { TopBar } from '@/components/layout/TopBar';
import { Sidebar } from '@/components/layout/Sidebar';
import { MainContent } from '@/components/layout/MainContent';
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
import { BootstrapWizard } from '@/components/bootstrap/BootstrapWizard';
import {
findFirstAccessibleLinkInLayout,
findFirstVisibleLinkInLayout,
isLinkAccessible,
visibleLayouts,
} from '@/lib/layout';
import { usePermissions } from '@/hooks/usePermissions';
import { Loader2 } from 'lucide-react';
export default function AdminPanel() {
const { t } = useTranslation();
const navigate = useNavigate();
const { section, '*': splat } = useParams<{
section?: string;
'*'?: string;
}>();
let viewName: string | undefined;
let id: string | undefined;
if (splat) {
const parts = splat.split('/');
if (parts[parts.length - 1] === 'new') {
id = 'new';
viewName = parts.slice(0, -1).join('/');
} else if (parts[parts.length - 1] === 'singleton') {
id = 'singleton';
viewName = parts.slice(0, -1).join('/');
} else {
const schemaStore = useSchemaStore.getState();
const fullAsView = parts.join('/');
if (schemaStore.schema?.objects[fullAsView]) {
viewName = fullAsView;
} else if (parts.length > 1) {
const candidateView = parts.slice(0, -1).join('/');
if (schemaStore.schema?.objects[candidateView]) {
viewName = candidateView;
id = parts[parts.length - 1];
} else {
viewName = fullAsView;
}
} else {
viewName = fullAsView;
}
}
}
const isSchemaLoaded = useSchemaStore((s) => s.isLoaded);
const schema = useSchemaStore((s) => s.schema);
const setSchema = useSchemaStore((s) => s.setSchema);
const setAccountInfo = useAccountStore((s) => s.setAccountInfo);
const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
const setSession = useAuthStore((s) => s.setSession);
const accessToken = useAuthStore((s) => s.accessToken);
const setActiveSection = useUIStore((s) => s.setActiveSection);
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const { canViewObject } = usePermissions();
const [initError, setInitError] = useState<string | null>(null);
const [initializing, setInitializing] = useState(!isSchemaLoaded);
useEffect(() => {
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
if (bypassToken && !accessToken) {
useAuthStore.getState().setTokens(bypassToken, '', 86400, '');
}
}, [accessToken]);
useEffect(() => {
if (isSchemaLoaded) return;
let cancelled = false;
async function init() {
try {
const session = await fetchSession();
const accounts: Record<string, { name: string; isPersonal: boolean }> = {};
for (const [accountId, info] of Object.entries(
session.accounts as Record<string, { name: string; isPersonal: boolean }>,
)) {
accounts[accountId] = { name: info.name, isPersonal: info.isPersonal };
}
const primaryAccounts = session.primaryAccounts as Record<string, string>;
const primaryAccountId =
primaryAccounts['urn:ietf:params:jmap:core'] ?? Object.values(primaryAccounts)[0] ?? Object.keys(accounts)[0];
const apiUrl = (session.apiUrl as string) || '/jmap';
const capabilities = session.capabilities as Record<string, Record<string, unknown>> | undefined;
const coreCapability = capabilities?.['urn:ietf:params:jmap:core'];
const maxObjectsInGet =
typeof coreCapability?.maxObjectsInGet === 'number' ? coreCapability.maxObjectsInGet : undefined;
const maxObjectsInSet =
typeof coreCapability?.maxObjectsInSet === 'number' ? coreCapability.maxObjectsInSet : undefined;
if (cancelled) return;
setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet);
const [schemaData, accountData] = await Promise.all([fetchSchema(), fetchAccountInfo()]);
if (cancelled) return;
setSchema(schemaData);
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
setLocale(accountData.locale);
setInitializing(false);
} catch (err) {
if (!cancelled) {
console.error('Initialization failed:', err);
setInitError(err instanceof Error ? err.message : t('errors.unexpectedError'));
setInitializing(false);
}
}
}
init();
return () => {
cancelled = true;
};
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
const hasPermission = useAccountStore((s) => s.hasPermission);
const isBootstrapMode = useMemo(() => {
if (!schema) return false;
if (!canViewObject('x:Bootstrap')) return false;
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const hasPerm = (perm: string) => hasPermission(perm);
return visibleLayouts(schema, edition, canGet, hasPerm).length === 0;
}, [schema, canViewObject, edition, hasObjectPermission, hasPermission]);
useEffect(() => {
if (!schema) return;
if (isBootstrapMode) return;
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
const hasPerm = (perm: string) => hasPermission(perm);
const layouts = visibleLayouts(schema, edition, canGet, hasPerm);
const pickDefault = (): { layoutName: string; link: string | null } | null => {
for (const layout of layouts) {
const link = findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPerm);
if (link) return { layoutName: layout.name, link };
}
if (layouts[0]) {
return {
layoutName: layouts[0].name,
link: findFirstVisibleLinkInLayout(schema, layouts[0], edition, canGet, hasPerm),
};
}
return null;
};
if (section && viewName && !isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) {
const fallback = pickDefault();
if (fallback && (fallback.layoutName !== section || fallback.link !== viewName)) {
setActiveSection(fallback.layoutName);
if (fallback.link) {
navigate(`/${fallback.layoutName}/${fallback.link}`, { replace: true });
}
return;
}
}
if (section) {
setActiveSection(section);
return;
}
const target = pickDefault();
if (target) {
setActiveSection(target.layoutName);
if (target.link) {
navigate(`/${target.layoutName}/${target.link}`, { replace: true });
}
}
}, [
section,
viewName,
schema,
setActiveSection,
navigate,
edition,
hasObjectPermission,
hasPermission,
isBootstrapMode,
]);
if (initializing) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground">{t('common.loading')}</p>
</div>
</div>
);
}
if (initError) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold text-destructive">{t('errors.loadSchemaFailed')}</h2>
<p className="mt-2 text-muted-foreground">{initError}</p>
<button
className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground hover:opacity-90"
onClick={() => window.location.reload()}
>
{t('common.continue')}
</button>
</div>
</div>
);
}
if (!schema) return null;
if (isBootstrapMode) {
return (
<ErrorBoundary>
<BootstrapWizard />
</ErrorBoundary>
);
}
return (
<div className="flex min-h-screen flex-col">
<TopBar />
<div className="flex flex-1">
<Sidebar />
<main
className={`flex-1 overflow-auto bg-content-background p-6 transition-all ${sidebarOpen ? 'md:ml-64' : ''}`}
>
<ErrorBoundary>
<MainContent viewName={viewName} id={id} section={section} />
</ErrorBoundary>
</main>
</div>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { type FormEvent, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowRight, Loader2 } from 'lucide-react';
import Logo from '@/components/common/Logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { startAuthFlow } from '@/services/auth/oauth';
export default function LoginPage() {
const { t } = useTranslation();
const location = useLocation();
const originalPath = (location.state as { from?: string } | null)?.from ?? null;
const [username, setUsername] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const trimmed = username.trim();
if (!trimmed) return;
setError(null);
setLoading(true);
try {
await startAuthFlow(trimmed, originalPath);
} catch (err) {
setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred'));
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-content-background px-4">
<Card className="w-full max-w-sm shadow-sm">
<CardHeader className="items-center text-center">
<Logo />
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<p className="text-center text-sm text-muted-foreground">
{t('login.prompt', 'Enter your account name to continue')}
</p>
<Input
id="username"
type="text"
autoComplete="username"
autoFocus
placeholder={t('login.usernamePlaceholder', 'user@example.com')}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loading}
aria-label={t('login.prompt', 'Enter your account name to continue')}
/>
</div>
{error && (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
)}
<Button type="submit" className="w-full" disabled={loading || !username.trim()}>
{loading ? (
<Loader2 className="animate-spin" />
) : (
<>
{t('login.continue', 'Continue')}
<ArrowRight />
</>
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export default function NotFound() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h1 className="text-4xl font-bold">404</h1>
<p className="mt-2 text-muted-foreground">{t('errors.notFound')}</p>
<Link to="/" className="mt-4 inline-block text-primary underline hover:no-underline">
{t('common.back')}
</Link>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { useAuthStore } from '@/stores/authStore';
import { getBasePath } from '@/lib/basePath';
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
export default function OAuthCallback() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function handleCallback() {
try {
const code = searchParams.get('code');
const stateParam = searchParams.get('state');
const errorParam = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
if (errorParam) {
throw new Error(errorDescription || errorParam);
}
if (!code || !stateParam) {
throw new Error(t('oauth.missingParams', 'Missing authorization code or state parameter'));
}
const { codeVerifier, tokenEndpoint, state, returnUrl } = getStoredOAuthData();
if (!state || state !== stateParam) {
throw new Error(t('oauth.stateMismatch', 'State parameter mismatch. Please try logging in again.'));
}
if (!codeVerifier || !tokenEndpoint) {
throw new Error(t('oauth.missingData', 'Missing OAuth session data. Please try logging in again.'));
}
const redirectUri = getOAuthRedirectUri();
const tokenResponse = await exchangeCode(code, codeVerifier, tokenEndpoint, redirectUri);
useAuthStore
.getState()
.setTokens(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenEndpoint);
clearStoredOAuthData();
const basePath = getBasePath();
const stripBase = (p: string) => (basePath && p.startsWith(basePath) ? p.slice(basePath.length) || '/' : p);
const candidate = returnUrl ? stripBase(returnUrl) : '/';
const isAuthPath =
candidate === '/login' ||
candidate.startsWith('/login?') ||
candidate === '/oauth/callback' ||
candidate.startsWith('/oauth/callback?');
const destination = isAuthPath ? '/' : candidate;
navigate(destination, { replace: true });
} catch (err) {
clearStoredOAuthData();
setError(err instanceof Error ? err.message : t('oauth.error', 'Authentication failed'));
}
}
handleCallback();
}, [navigate, searchParams, t]);
if (error) {
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<div className="w-full max-w-sm space-y-4 text-center">
<p className="text-sm text-destructive" role="alert">
{error}
</p>
<a href={`${getBasePath()}/login`} className="text-sm text-primary underline-offset-4 hover:underline">
{t('oauth.backToLogin', 'Back to login')}
</a>
</div>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">{t('oauth.processing', 'Completing sign in...')}</p>
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useAuthStore } from '../stores/authStore';
import { getBasePath } from '@/lib/basePath';
export function getApiBaseUrl(): string {
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
if (envUrl && envUrl.length > 0) {
return envUrl.replace(/\/+$/, '');
}
return window.location.origin;
}
export class ApiError extends Error {
status: number;
statusText: string;
body: unknown;
constructor(status: number, statusText: string, body: unknown) {
super(`API error ${status}: ${statusText}`);
this.name = 'ApiError';
this.status = status;
this.statusText = statusText;
this.body = body;
}
}
let refreshPromise: Promise<void> | null = null;
export async function refreshAccessToken(): Promise<void> {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
const { refreshToken, tokenEndpoint, logout } = useAuthStore.getState();
if (!refreshToken || !tokenEndpoint) {
logout();
window.location.href = `${getBasePath()}/login`;
throw new Error('No refresh token or token endpoint available');
}
const clientId = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
try {
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: clientId,
}),
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`);
}
const data = await response.json();
useAuthStore
.getState()
.setTokens(data.access_token, data.refresh_token ?? refreshToken, data.expires_in, tokenEndpoint);
} catch (error) {
useAuthStore.getState().logout();
window.location.href = `${getBasePath()}/login`;
throw error;
}
})();
try {
await refreshPromise;
} finally {
refreshPromise = null;
}
}
export async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
const store = useAuthStore.getState();
if (store.isTokenExpiringSoon() && store.refreshToken) {
await refreshAccessToken();
}
const makeRequest = async (): Promise<Response> => {
const { accessToken } = useAuthStore.getState();
const url = `${getApiBaseUrl()}${path}`;
const headers = new Headers(options?.headers);
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
const response = await fetch(url, {
...options,
headers,
redirect: 'follow',
});
return response;
};
let response = await makeRequest();
if (response.status === 401 && useAuthStore.getState().refreshToken) {
try {
await refreshAccessToken();
response = await makeRequest();
} catch {
throw new ApiError(401, 'Unauthorized', null);
}
}
if (!response.ok) {
let body: unknown = null;
try {
body = await response.json();
// eslint-disable-next-line no-empty
} catch {}
throw new ApiError(response.status, response.statusText, body);
}
return response;
}
+104
View File
@@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect } from 'vitest';
import { generateCodeVerifier, generateCodeChallenge } from './oauth';
const UNRESERVED_RE = /^[A-Za-z0-9\-._~]+$/;
describe('generateCodeVerifier', () => {
it('defaults to 64 characters', () => {
const verifier = generateCodeVerifier();
expect(verifier).toHaveLength(64);
});
it('honours a custom length', () => {
expect(generateCodeVerifier(43)).toHaveLength(43);
expect(generateCodeVerifier(128)).toHaveLength(128);
});
it('rejects lengths outside RFC 7636 range', () => {
expect(() => generateCodeVerifier(42)).toThrow();
expect(() => generateCodeVerifier(129)).toThrow();
});
it('returns different values on each call', () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toBe(b);
});
it('only contains RFC 3986 unreserved characters', () => {
const verifier = generateCodeVerifier();
expect(verifier).toMatch(UNRESERVED_RE);
});
it('consistently produces unreserved-only output across multiple calls', () => {
for (let i = 0; i < 20; i++) {
const v = generateCodeVerifier();
expect(v).toMatch(UNRESERVED_RE);
expect(v).toHaveLength(64);
}
});
it('exercises every character in the alphabet given enough samples', () => {
const seen = new Set<string>();
for (let i = 0; i < 50; i++) {
for (const ch of generateCodeVerifier(128)) seen.add(ch);
}
for (const ch of '-._~') {
expect(seen.has(ch)).toBe(true);
}
});
});
describe('generateCodeChallenge', () => {
it('returns a base64url string without +, /, or =', async () => {
const { challenge } = await generateCodeChallenge('test-verifier');
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces consistent output for same input', async () => {
const a = await generateCodeChallenge('same-input');
const b = await generateCodeChallenge('same-input');
expect(a).toEqual(b);
});
it('produces different output for different inputs', async () => {
const a = await generateCodeChallenge('input-one');
const b = await generateCodeChallenge('input-two');
expect(a.challenge).not.toBe(b.challenge);
});
it('returns a non-empty string', async () => {
const { challenge } = await generateCodeChallenge('anything');
expect(challenge.length).toBeGreaterThan(0);
});
it('produces a SHA-256 sized output (43 base64url chars for 32 bytes) when S256 is used', async () => {
const { challenge, method } = await generateCodeChallenge('test');
expect(method).toBe('S256');
expect(challenge).toHaveLength(43);
});
it('falls back to plain when crypto.subtle is unavailable', async () => {
const originalSubtle = crypto.subtle;
Object.defineProperty(crypto, 'subtle', {
configurable: true,
get: () => undefined,
});
try {
const { challenge, method } = await generateCodeChallenge('plain-verifier');
expect(method).toBe('plain');
expect(challenge).toBe('plain-verifier');
} finally {
Object.defineProperty(crypto, 'subtle', {
configurable: true,
value: originalSubtle,
});
}
});
});
+182
View File
@@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { getApiBaseUrl } from '@/services/api';
import { getBasePath } from '@/lib/basePath';
import i18n from '@/i18n';
const CLIENT_ID = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
const SCOPES = import.meta.env.VITE_OAUTH_SCOPES as string | undefined;
const SESSION_PREFIX = 'stalwart-oauth-';
interface DiscoveryResponse {
authorization_endpoint: string;
token_endpoint: string;
}
export async function discover(username: string): Promise<DiscoveryResponse> {
const url = `${getApiBaseUrl()}/api/discover/${encodeURIComponent(username)}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(
i18n.t('oauth.discoveryFailed', 'Discovery failed for "{{username}}": {{status}} {{statusText}}', {
username,
status: response.status,
statusText: response.statusText,
}),
);
}
return response.json() as Promise<DiscoveryResponse>;
}
const UNRESERVED = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
export function generateCodeVerifier(length: number = 64): string {
if (length < 43 || length > 128) {
throw new Error(`code_verifier length must be 43-128, got ${length}`);
}
const out: string[] = [];
while (out.length < length) {
const buf = new Uint8Array(length * 2);
crypto.getRandomValues(buf);
for (let i = 0; i < buf.length && out.length < length; i++) {
const b = buf[i];
if (b < 198) {
out.push(UNRESERVED[b % 66]);
}
}
}
return out.join('');
}
export async function generateCodeChallenge(
verifier: string,
): Promise<{ challenge: string; method: 'S256' | 'plain' }> {
if (typeof crypto === 'undefined' || typeof crypto.subtle === 'undefined') {
return { challenge: verifier, method: 'plain' };
}
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return { challenge: base64UrlEncode(new Uint8Array(digest)), method: 'S256' };
}
function generateState(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
function base64UrlEncode(bytes: Uint8Array): string {
const binString = Array.from(bytes, (b) => String.fromCodePoint(b)).join('');
return btoa(binString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
interface TokenResponse {
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
}
export async function exchangeCode(
code: string,
codeVerifier: string,
tokenEndpoint: string,
redirectUri: string,
): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
client_id: CLIENT_ID,
redirect_uri: redirectUri,
});
const response = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!response.ok) {
throw new Error(
i18n.t('oauth.tokenExchangeFailed', 'Token exchange failed: {{status}} {{statusText}}', {
status: response.status,
statusText: response.statusText,
}),
);
}
return response.json() as Promise<TokenResponse>;
}
function getRedirectUri(): string {
const basePath = getBasePath();
return `${window.location.origin}${basePath}/oauth/callback`;
}
export async function startAuthFlow(username: string, returnUrl?: string | null): Promise<void> {
const { authorization_endpoint, token_endpoint } = await discover(username);
const codeVerifier = generateCodeVerifier();
const { challenge: codeChallenge, method: codeChallengeMethod } = await generateCodeChallenge(codeVerifier);
const state = generateState();
const candidate = returnUrl ?? window.location.pathname + window.location.search;
const basePath = getBasePath();
const stripped = candidate.startsWith(basePath) ? candidate.slice(basePath.length) : candidate;
const isAuthPath =
stripped === '/login' ||
stripped.startsWith('/login?') ||
stripped === '/oauth/callback' ||
stripped.startsWith('/oauth/callback?');
const safeReturnUrl = isAuthPath ? '' : candidate;
sessionStorage.setItem(`${SESSION_PREFIX}code_verifier`, codeVerifier);
sessionStorage.setItem(`${SESSION_PREFIX}token_endpoint`, token_endpoint);
sessionStorage.setItem(`${SESSION_PREFIX}state`, state);
sessionStorage.setItem(`${SESSION_PREFIX}return_url`, safeReturnUrl);
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: getRedirectUri(),
code_challenge: codeChallenge,
code_challenge_method: codeChallengeMethod,
state,
login_hint: username,
});
if (SCOPES && SCOPES.length > 0) {
params.set('scope', SCOPES);
}
window.location.href = `${authorization_endpoint}?${params.toString()}`;
}
export function getStoredOAuthData() {
return {
codeVerifier: sessionStorage.getItem(`${SESSION_PREFIX}code_verifier`),
tokenEndpoint: sessionStorage.getItem(`${SESSION_PREFIX}token_endpoint`),
state: sessionStorage.getItem(`${SESSION_PREFIX}state`),
returnUrl: sessionStorage.getItem(`${SESSION_PREFIX}return_url`),
};
}
export function clearStoredOAuthData(): void {
sessionStorage.removeItem(`${SESSION_PREFIX}code_verifier`);
sessionStorage.removeItem(`${SESSION_PREFIX}token_endpoint`);
sessionStorage.removeItem(`${SESSION_PREFIX}state`);
sessionStorage.removeItem(`${SESSION_PREFIX}return_url`);
}
export function getOAuthRedirectUri(): string {
return getRedirectUri();
}
+239
View File
@@ -0,0 +1,239 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { useAuthStore } from '@/stores/authStore';
import { apiFetch } from '@/services/api';
import { logJmapExchange } from '@/lib/debug';
import type { JmapMethodCall, JmapMethodResponse, JmapQueryResponse, JmapResponse } from '@/types/jmap';
import type { Schema } from '@/types/schema';
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
export function getAccountId(objectType: string): string {
const { primaryAccountId, activeAccountId } = useAuthStore.getState();
if (objectType.startsWith('x:')) {
if (!primaryAccountId) throw new Error('No primary account ID available');
return primaryAccountId;
}
if (!activeAccountId) throw new Error('No active account ID available');
return activeAccountId;
}
export async function jmapRequest(methodCalls: JmapMethodCall[], signal?: AbortSignal): Promise<JmapMethodResponse[]> {
const { apiUrl } = useAuthStore.getState();
let path = apiUrl || '/jmap';
if (path.startsWith('http://') || path.startsWith('https://')) {
try {
path = new URL(path).pathname;
} catch {
path = '/jmap';
}
}
const startedAt = performance.now();
const response = await apiFetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
using: JMAP_USING,
methodCalls,
}),
signal,
});
const data = (await response.json()) as JmapResponse;
logJmapExchange(methodCalls, data.methodResponses, performance.now() - startedAt);
if (import.meta.env.DEV) {
for (const [methodName, result, callId] of data.methodResponses) {
if (methodName === 'error') {
console.error(`JMAP error [${callId}]:`, result);
}
}
}
return data.methodResponses;
}
export async function jmapGet(
objectType: string,
accountId: string,
ids: string[] | null,
properties?: string[],
signal?: AbortSignal,
): Promise<JmapMethodResponse[]> {
const args: Record<string, unknown> = { accountId, ids };
if (properties) {
args.properties = properties;
}
return jmapRequest([[`${objectType}/get`, args, '0']], signal);
}
interface JmapQueryOptions {
filter?: Record<string, unknown>;
sort?: Record<string, unknown>[];
limit?: number;
position?: number;
anchor?: string;
anchorOffset?: number;
calculateTotal?: boolean;
}
export async function jmapQuery(
objectType: string,
accountId: string,
options: JmapQueryOptions = {},
): Promise<JmapMethodResponse[]> {
const args: Record<string, unknown> = { accountId, ...options };
return jmapRequest([[`${objectType}/query`, args, '0']]);
}
interface JmapSetOptions {
create?: Record<string, Record<string, unknown>>;
update?: Record<string, Record<string, unknown>>;
destroy?: string[];
}
export async function jmapSet(
objectType: string,
accountId: string,
options: JmapSetOptions = {},
): Promise<JmapMethodResponse[]> {
const args: Record<string, unknown> = { accountId, ...options };
return jmapRequest([[`${objectType}/set`, args, '0']]);
}
export async function jmapQueryAndGet(
objectType: string,
accountId: string,
queryOptions: JmapQueryOptions = {},
properties?: string[],
): Promise<JmapMethodResponse[]> {
const queryArgs: Record<string, unknown> = {
accountId,
...queryOptions,
};
const getArgs: Record<string, unknown> = {
accountId,
'#ids': {
resultOf: '0',
name: `${objectType}/query`,
path: '/ids',
},
};
if (properties) {
getArgs.properties = properties;
}
return jmapRequest([
[`${objectType}/query`, queryArgs, '0'],
[`${objectType}/get`, getArgs, '1'],
]);
}
export async function jmapGetBatched(
objectType: string,
accountId: string,
ids: string[],
properties?: string[],
signal?: AbortSignal,
): Promise<Record<string, unknown>[]> {
if (ids.length === 0) return [];
const batchSize = useAuthStore.getState().maxObjectsInGet;
const allItems: Record<string, unknown>[] = [];
for (let offset = 0; offset < ids.length; offset += batchSize) {
if (signal?.aborted) break;
const batchIds = ids.slice(offset, offset + batchSize);
const args: Record<string, unknown> = { accountId, ids: batchIds };
if (properties) args.properties = properties;
const responses = await jmapRequest([[`${objectType}/get`, args, '0']], signal);
const result = responses[0];
if (result[0] === 'error') {
throw new Error(`JMAP ${objectType}/get error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
}
const list = (result[1] as { list?: Record<string, unknown>[] }).list ?? [];
allItems.push(...list);
}
return allItems;
}
export async function jmapQueryAll(
objectType: string,
accountId: string,
queryOptions: JmapQueryOptions = {},
signal?: AbortSignal,
): Promise<string[]> {
const allIds: string[] = [];
let anchor: string | undefined;
const MAX_PAGES = 1000;
for (let page = 0; page < MAX_PAGES; page++) {
if (signal?.aborted) break;
const args: Record<string, unknown> = { accountId, ...queryOptions };
if (anchor) {
delete args.position;
args.anchor = anchor;
args.anchorOffset = 1;
}
const responses = await jmapRequest([[`${objectType}/query`, args, '0']], signal);
const result = responses[0];
if (result[0] === 'error') {
throw new Error(`JMAP ${objectType}/query error: ${(result[1] as Record<string, unknown>).type ?? 'unknown'}`);
}
const data = result[1] as unknown as JmapQueryResponse;
const pageIds = data.ids ?? [];
if (pageIds.length === 0) break;
allIds.push(...pageIds);
if (data.limit === undefined || data.limit === null) break;
anchor = pageIds[pageIds.length - 1];
}
return allIds;
}
export async function jmapQueryAllAndGet(
objectType: string,
accountId: string,
queryOptions: JmapQueryOptions = {},
properties?: string[],
signal?: AbortSignal,
): Promise<{ ids: string[]; list: Record<string, unknown>[] }> {
const ids = await jmapQueryAll(objectType, accountId, queryOptions, signal);
if (ids.length === 0) return { ids, list: [] };
const list = await jmapGetBatched(objectType, accountId, ids, properties, signal);
return { ids, list };
}
export async function fetchSession(): Promise<Record<string, unknown>> {
const response = await apiFetch('/jmap/session');
return response.json() as Promise<Record<string, unknown>>;
}
export async function fetchSchema(): Promise<Schema> {
const response = await apiFetch('/api/schema');
return response.json() as Promise<Schema>;
}
interface AccountInfoResponse {
permissions: string[];
edition: 'enterprise' | 'community' | 'oss';
locale: string;
}
export async function fetchAccountInfo(): Promise<AccountInfoResponse> {
const response = await apiFetch('/api/account');
return response.json() as Promise<AccountInfoResponse>;
}
+109
View File
@@ -0,0 +1,109 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useAccountStore } from './accountStore';
describe('accountStore', () => {
beforeEach(() => {
useAccountStore.setState({
permissions: [],
edition: 'community',
locale: 'en',
});
});
describe('setAccountInfo', () => {
it('stores permissions, edition, and locale', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet', 'sysAccountCreate'], 'enterprise', 'de');
const state = useAccountStore.getState();
expect(state.permissions).toEqual(['sysAccountGet', 'sysAccountCreate']);
expect(state.edition).toBe('enterprise');
expect(state.locale).toBe('de');
});
it('replaces previous values on subsequent calls', () => {
const { setAccountInfo } = useAccountStore.getState();
setAccountInfo(['a'], 'enterprise', 'fr');
setAccountInfo(['b', 'c'], 'oss', 'ja');
const state = useAccountStore.getState();
expect(state.permissions).toEqual(['b', 'c']);
expect(state.edition).toBe('oss');
expect(state.locale).toBe('ja');
});
it('accepts empty permissions array', () => {
useAccountStore.getState().setAccountInfo([], 'community', 'en');
expect(useAccountStore.getState().permissions).toEqual([]);
});
});
describe('hasPermission', () => {
it('returns true when the permission exists', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet', 'sysAccountCreate'], 'community', 'en');
expect(useAccountStore.getState().hasPermission('sysAccountGet')).toBe(true);
});
it('returns false when the permission does not exist', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
expect(useAccountStore.getState().hasPermission('sysAccountDestroy')).toBe(false);
});
it('returns false when permissions are empty', () => {
expect(useAccountStore.getState().hasPermission('anything')).toBe(false);
});
});
describe('hasObjectPermission', () => {
it('builds correct permission string from prefix and action', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
expect(useAccountStore.getState().hasObjectPermission('sysAccount', 'Get')).toBe(true);
});
it('returns false for non-matching action', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
expect(useAccountStore.getState().hasObjectPermission('sysAccount', 'Destroy')).toBe(false);
});
it('returns false for non-matching prefix', () => {
useAccountStore.getState().setAccountInfo(['sysAccountGet'], 'community', 'en');
expect(useAccountStore.getState().hasObjectPermission('sysDomain', 'Get')).toBe(false);
});
it('works with all action types', () => {
useAccountStore.getState().setAccountInfo(['fooCreate', 'fooUpdate', 'fooDestroy', 'fooGet'], 'community', 'en');
const state = useAccountStore.getState();
expect(state.hasObjectPermission('foo', 'Create')).toBe(true);
expect(state.hasObjectPermission('foo', 'Update')).toBe(true);
expect(state.hasObjectPermission('foo', 'Destroy')).toBe(true);
expect(state.hasObjectPermission('foo', 'Get')).toBe(true);
});
});
describe('edition', () => {
it('defaults to community', () => {
expect(useAccountStore.getState().edition).toBe('community');
});
it('can be changed to enterprise', () => {
useAccountStore.getState().setAccountInfo([], 'enterprise', 'en');
expect(useAccountStore.getState().edition).toBe('enterprise');
});
it('can be changed to oss', () => {
useAccountStore.getState().setAccountInfo([], 'oss', 'en');
expect(useAccountStore.getState().edition).toBe('oss');
});
});
});
+37
View File
@@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
type Edition = 'enterprise' | 'community' | 'oss';
interface AccountState {
permissions: string[];
edition: Edition;
locale: string;
setAccountInfo: (permissions: string[], edition: Edition, locale: string) => void;
hasPermission: (perm: string) => boolean;
hasObjectPermission: (prefix: string, action: 'Get' | 'Query' | 'Create' | 'Update' | 'Destroy') => boolean;
}
export const useAccountStore = create<AccountState>()((set, get) => ({
permissions: [],
edition: 'community',
locale: 'en',
setAccountInfo: (permissions, edition, locale) => {
set({ permissions, edition, locale });
},
hasPermission: (perm) => {
return get().permissions.includes(perm);
},
hasObjectPermission: (prefix, action) => {
return get().permissions.includes(`${prefix}${action}`);
},
}));
+180
View File
@@ -0,0 +1,180 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useAuthStore } from './authStore';
const initialState = {
accessToken: null,
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
apiUrl: null,
};
describe('authStore', () => {
beforeEach(() => {
useAuthStore.setState(initialState);
vi.restoreAllMocks();
});
describe('setTokens', () => {
it('stores access and refresh tokens', () => {
useAuthStore.getState().setTokens('acc-123', 'ref-456', 3600, 'https://auth/token');
const state = useAuthStore.getState();
expect(state.accessToken).toBe('acc-123');
expect(state.refreshToken).toBe('ref-456');
});
it('computes expiration time from expiresIn', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
useAuthStore.getState().setTokens('a', 'r', 3600, 'https://auth/token');
expect(useAuthStore.getState().tokenExpiresAt).toBe(now + 3600 * 1000);
});
it('stores token endpoint', () => {
useAuthStore.getState().setTokens('a', 'r', 3600, 'https://example.com/token');
expect(useAuthStore.getState().tokenEndpoint).toBe('https://example.com/token');
});
});
describe('isAuthenticated', () => {
it('returns false when no token is set', () => {
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
it('returns true when token exists and is not expired', () => {
const now = Date.now();
useAuthStore.setState({
accessToken: 'tok',
tokenExpiresAt: now + 60_000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(true);
});
it('returns false when token is expired', () => {
useAuthStore.setState({
accessToken: 'tok',
tokenExpiresAt: Date.now() - 1000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
it('returns false when accessToken is null even if expiry is in the future', () => {
useAuthStore.setState({
accessToken: null,
tokenExpiresAt: Date.now() + 60_000,
});
expect(useAuthStore.getState().isAuthenticated()).toBe(false);
});
});
describe('isTokenExpiringSoon', () => {
it('returns false when tokenExpiresAt is null', () => {
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(false);
});
it('returns true within 60 seconds of expiration', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() + 30_000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(true);
});
it('returns false when more than 60 seconds remain', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() + 120_000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(false);
});
it('returns true when token is already expired', () => {
useAuthStore.setState({ tokenExpiresAt: Date.now() - 5000 });
expect(useAuthStore.getState().isTokenExpiringSoon()).toBe(true);
});
});
describe('setSession', () => {
it('stores accounts and primaryAccountId', () => {
const accounts = {
'acc-1': { name: 'Personal', isPersonal: true },
'acc-2': { name: 'Work', isPersonal: false },
};
useAuthStore.getState().setSession(accounts, 'acc-1', 'https://api');
const state = useAuthStore.getState();
expect(state.accounts).toEqual(accounts);
expect(state.primaryAccountId).toBe('acc-1');
expect(state.apiUrl).toBe('https://api');
});
it('sets activeAccountId to primaryAccountId', () => {
useAuthStore.getState().setSession({ 'acc-1': { name: 'A', isPersonal: true } }, 'acc-1', 'https://api');
expect(useAuthStore.getState().activeAccountId).toBe('acc-1');
});
});
describe('switchAccount', () => {
it('changes activeAccountId when account exists', () => {
useAuthStore.setState({
accounts: {
a1: { name: 'A1', isPersonal: true },
a2: { name: 'A2', isPersonal: false },
},
activeAccountId: 'a1',
});
useAuthStore.getState().switchAccount('a2');
expect(useAuthStore.getState().activeAccountId).toBe('a2');
});
it('does nothing for unknown account id', () => {
useAuthStore.setState({
accounts: { a1: { name: 'A1', isPersonal: true } },
activeAccountId: 'a1',
});
useAuthStore.getState().switchAccount('nonexistent');
expect(useAuthStore.getState().activeAccountId).toBe('a1');
});
});
describe('logout', () => {
it('clears all state', () => {
useAuthStore.setState({
accessToken: 'tok',
refreshToken: 'ref',
tokenExpiresAt: 99999,
tokenEndpoint: 'https://auth/token',
accounts: { a: { name: 'A', isPersonal: true } },
primaryAccountId: 'a',
activeAccountId: 'a',
apiUrl: 'https://api',
});
useAuthStore.getState().logout();
const state = useAuthStore.getState();
expect(state.accessToken).toBeNull();
expect(state.refreshToken).toBeNull();
expect(state.tokenExpiresAt).toBeNull();
expect(state.tokenEndpoint).toBeNull();
expect(state.accounts).toEqual({});
expect(state.primaryAccountId).toBeNull();
expect(state.activeAccountId).toBeNull();
expect(state.apiUrl).toBeNull();
});
});
});
+129
View File
@@ -0,0 +1,129 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AccountInfo {
name: string;
isPersonal: boolean;
}
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
tokenExpiresAt: number | null;
tokenEndpoint: string | null;
accounts: Record<string, AccountInfo>;
primaryAccountId: string | null;
activeAccountId: string | null;
apiUrl: string | null;
maxObjectsInGet: number;
maxObjectsInSet: number;
setTokens: (access: string, refresh: string, expiresIn: number, tokenEndpoint: string) => void;
setSession: (
accounts: Record<string, AccountInfo>,
primaryAccountId: string,
apiUrl: string,
maxObjectsInGet?: number,
maxObjectsInSet?: number,
) => void;
switchAccount: (accountId: string) => void;
logout: () => void;
isAuthenticated: () => boolean;
isTokenExpiringSoon: () => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
accessToken: null,
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
apiUrl: null,
maxObjectsInGet: 500,
maxObjectsInSet: 500,
setTokens: (access, refresh, expiresIn, tokenEndpoint) => {
set({
accessToken: access,
refreshToken: refresh,
tokenExpiresAt: Date.now() + expiresIn * 1000,
tokenEndpoint,
});
},
setSession: (accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet) => {
set({
accounts,
primaryAccountId,
activeAccountId: primaryAccountId,
apiUrl,
...(maxObjectsInGet !== undefined ? { maxObjectsInGet } : {}),
...(maxObjectsInSet !== undefined ? { maxObjectsInSet } : {}),
});
},
switchAccount: (accountId) => {
const { accounts } = get();
if (accounts[accountId]) {
set({ activeAccountId: accountId });
}
},
logout: () => {
set({
accessToken: null,
refreshToken: null,
tokenExpiresAt: null,
tokenEndpoint: null,
accounts: {},
primaryAccountId: null,
activeAccountId: null,
apiUrl: null,
});
},
isAuthenticated: () => {
const { accessToken, tokenExpiresAt } = get();
return accessToken !== null && tokenExpiresAt !== null && Date.now() < tokenExpiresAt;
},
isTokenExpiringSoon: () => {
const { tokenExpiresAt } = get();
if (tokenExpiresAt === null) return false;
return tokenExpiresAt - Date.now() < 60_000;
},
}),
{
name: 'stalwart-auth',
storage: {
getItem: (name) => {
const value = sessionStorage.getItem(name);
return value ? JSON.parse(value) : null;
},
setItem: (name, value) => {
sessionStorage.setItem(name, JSON.stringify(value));
},
removeItem: (name) => {
sessionStorage.removeItem(name);
},
},
partialize: (state) =>
({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
tokenExpiresAt: state.tokenExpiresAt,
tokenEndpoint: state.tokenEndpoint,
}) as AuthState,
},
),
);
+94
View File
@@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { useCacheStore } from './cacheStore';
describe('cacheStore', () => {
beforeEach(() => {
useCacheStore.setState({ displayNames: {} });
});
describe('setDisplayNames', () => {
it('stores entries for an object type', () => {
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice', u2: 'Bob' });
expect(useCacheStore.getState().displayNames).toEqual({
user: { u1: 'Alice', u2: 'Bob' },
});
});
it('merges with existing entries for the same object type', () => {
const { setDisplayNames } = useCacheStore.getState();
setDisplayNames('user', { u1: 'Alice' });
setDisplayNames('user', { u2: 'Bob' });
expect(useCacheStore.getState().displayNames.user).toEqual({
u1: 'Alice',
u2: 'Bob',
});
});
it('overwrites individual entries when keys collide', () => {
const { setDisplayNames } = useCacheStore.getState();
setDisplayNames('user', { u1: 'Alice' });
setDisplayNames('user', { u1: 'Alicia' });
expect(useCacheStore.getState().displayNames.user.u1).toBe('Alicia');
});
});
describe('getDisplayName', () => {
it('returns cached name', () => {
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
expect(useCacheStore.getState().getDisplayName('user', 'u1')).toBe('Alice');
});
it('returns undefined for unknown id', () => {
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
expect(useCacheStore.getState().getDisplayName('user', 'u99')).toBeUndefined();
});
it('returns undefined for unknown object type', () => {
expect(useCacheStore.getState().getDisplayName('domain', 'd1')).toBeUndefined();
});
});
describe('invalidateCache', () => {
it('removes entries for a specific object type', () => {
const { setDisplayNames } = useCacheStore.getState();
setDisplayNames('user', { u1: 'Alice' });
setDisplayNames('domain', { d1: 'example.com' });
useCacheStore.getState().invalidateCache('user');
const state = useCacheStore.getState();
expect(state.displayNames.user).toBeUndefined();
expect(state.displayNames.domain).toEqual({ d1: 'example.com' });
});
it('is a no-op when object type does not exist', () => {
useCacheStore.getState().setDisplayNames('user', { u1: 'Alice' });
useCacheStore.getState().invalidateCache('nonexistent');
expect(useCacheStore.getState().displayNames.user).toEqual({ u1: 'Alice' });
});
});
describe('multiple object types', () => {
it('stores different object types independently', () => {
const { setDisplayNames } = useCacheStore.getState();
setDisplayNames('user', { u1: 'Alice' });
setDisplayNames('domain', { d1: 'example.com' });
setDisplayNames('group', { g1: 'Admins' });
const state = useCacheStore.getState();
expect(state.getDisplayName('user', 'u1')).toBe('Alice');
expect(state.getDisplayName('domain', 'd1')).toBe('example.com');
expect(state.getDisplayName('group', 'g1')).toBe('Admins');
});
});
});
+78
View File
@@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
export interface ObjectListEntry {
id: string;
label: string;
}
interface CacheState {
displayNames: Record<string, Record<string, string>>;
objectLists: Record<string, ObjectListEntry[]>;
setDisplayNames: (objectType: string, entries: Record<string, string>) => void;
getDisplayName: (objectType: string, id: string) => string | undefined;
invalidateCache: (objectType: string) => void;
setObjectList: (key: string, entries: ObjectListEntry[]) => void;
getObjectList: (key: string) => ObjectListEntry[] | undefined;
invalidateObjectList: (key: string) => void;
invalidateAllObjectLists: () => void;
}
export const useCacheStore = create<CacheState>()((set, get) => ({
displayNames: {},
objectLists: {},
setDisplayNames: (objectType, entries) => {
set((state) => ({
displayNames: {
...state.displayNames,
[objectType]: {
...state.displayNames[objectType],
...entries,
},
},
}));
},
getDisplayName: (objectType, id) => {
return get().displayNames[objectType]?.[id];
},
invalidateCache: (objectType) => {
set((state) => {
const { [objectType]: _removed, ...rest } = state.displayNames;
void _removed;
return { displayNames: rest };
});
},
setObjectList: (key, entries) => {
set((state) => ({
objectLists: { ...state.objectLists, [key]: entries },
}));
},
getObjectList: (key) => {
return get().objectLists[key];
},
invalidateObjectList: (key) => {
set((state) => {
const { [key]: _removed, ...rest } = state.objectLists;
void _removed;
return { objectLists: rest };
});
},
invalidateAllObjectLists: () => {
set({ objectLists: {} });
},
}));
+176
View File
@@ -0,0 +1,176 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import type { Schema, LayoutSubItem } from '@/types/schema';
export interface SearchIndexEntry {
text: string;
type: 'link' | 'field' | 'form';
viewName: string;
section: string;
breadcrumb: string;
icon?: string;
objectType?: 'object' | 'singleton' | 'view';
keywords?: string[];
}
interface SchemaState {
schema: Schema | null;
isLoaded: boolean;
viewToSection: Record<string, string>;
searchIndex: SearchIndexEntry[];
setSchema: (schema: Schema) => void;
}
function walkLayouts(schema: Schema): { viewToSection: Record<string, string>; linkEntries: SearchIndexEntry[] } {
const viewToSection: Record<string, string> = {};
const linkEntries: SearchIndexEntry[] = [];
function visit(items: LayoutSubItem[], sectionName: string, parentPath: string): void {
for (const sub of items) {
if (sub.type === 'link') {
if (!(sub.viewName in viewToSection)) {
viewToSection[sub.viewName] = sectionName;
}
linkEntries.push({
text: sub.name,
type: 'link',
viewName: sub.viewName,
section: sectionName,
breadcrumb: `${parentPath} > ${sub.name}`,
});
} else if (sub.type === 'container') {
visit(sub.items, sectionName, `${parentPath} > ${sub.name}`);
}
}
}
for (const layout of schema.layouts) {
const sectionName = layout.name;
for (const item of layout.items) {
if ('link' in item) {
if (!(item.link.viewName in viewToSection)) {
viewToSection[item.link.viewName] = sectionName;
}
linkEntries.push({
text: item.link.name,
type: 'link',
viewName: item.link.viewName,
section: sectionName,
breadcrumb: `${sectionName} > ${item.link.name}`,
icon: item.link.icon,
});
} else if ('container' in item) {
visit(item.container.items, sectionName, `${sectionName} > ${item.container.name}`);
}
}
}
return { viewToSection, linkEntries };
}
function buildSearchIndex(
schema: Schema,
viewToSection: Record<string, string>,
linkEntries: SearchIndexEntry[],
): SearchIndexEntry[] {
const entries: SearchIndexEntry[] = [...linkEntries];
function displayNameFor(viewName: string): string {
const obj = schema.objects[viewName];
if (!obj) return viewName.replace(/^x:/, '');
let resolvedName = viewName;
let resolvedObj = obj;
if (obj.type === 'view') {
const parent = schema.objects[obj.objectName];
if (parent && parent.type !== 'view') {
resolvedName = obj.objectName;
resolvedObj = parent;
}
}
if (resolvedObj.type === 'singleton') {
const form = schema.forms[viewName] ?? schema.forms[resolvedName];
if (form?.title) return form.title;
}
if (resolvedObj.type === 'object') {
const list = schema.lists[viewName] ?? schema.lists[resolvedName];
if (list?.singularName) {
return list.singularName.charAt(0).toUpperCase() + list.singularName.slice(1);
}
}
return viewName.replace(/^x:/, '');
}
for (const [name, obj] of Object.entries(schema.objects)) {
if (obj.type !== 'view' && obj.description) {
const section = viewToSection[name] ?? '';
const display = displayNameFor(name);
entries.push({
text: obj.description,
type: 'link',
viewName: name,
section,
breadcrumb: section ? `${section} > ${display}` : display,
objectType: obj.type,
});
}
}
for (const [formKey, form] of Object.entries(schema.forms)) {
const viewName = formKey;
const section = viewToSection[viewName] ?? '';
const display = displayNameFor(viewName);
for (const formSection of form.sections) {
if (formSection.title) {
entries.push({
text: formSection.title,
type: 'form',
viewName,
section,
breadcrumb: section ? `${section} > ${display} > ${formSection.title}` : `${display} > ${formSection.title}`,
});
}
for (const field of formSection.fields) {
const keywords: string[] = [];
if (field.name && field.name !== '@type') {
keywords.push(field.name);
}
entries.push({
text: field.label,
type: 'field',
viewName,
section,
breadcrumb: section ? `${section} > ${display} > ${field.label}` : `${display} > ${field.label}`,
keywords,
});
}
}
}
return entries;
}
export const useSchemaStore = create<SchemaState>()((set) => ({
schema: null,
isLoaded: false,
viewToSection: {},
searchIndex: [],
setSchema: (schema) => {
const { viewToSection, linkEntries } = walkLayouts(schema);
const searchIndex = buildSearchIndex(schema, viewToSection, linkEntries);
set({
schema,
isLoaded: true,
viewToSection,
searchIndex,
});
},
}));
+77
View File
@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'light' | 'dark';
interface UIState {
theme: Theme;
sidebarOpen: boolean;
activeSection: string;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
setActiveSection: (section: string) => void;
}
function applyThemeClass(theme: Theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
export const useUIStore = create<UIState>()(
persist(
(set, get) => ({
theme:
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
sidebarOpen: true,
activeSection: '',
toggleTheme: () => {
const next = get().theme === 'light' ? 'dark' : 'light';
applyThemeClass(next);
set({ theme: next });
},
setTheme: (theme) => {
applyThemeClass(theme);
set({ theme });
},
toggleSidebar: () => {
set({ sidebarOpen: !get().sidebarOpen });
},
setSidebarOpen: (open) => {
set({ sidebarOpen: open });
},
setActiveSection: (section) => {
set({ activeSection: section });
},
}),
{
name: 'stalwart-ui',
partialize: (state) => ({
theme: state.theme,
}),
onRehydrateStorage: () => {
return (state) => {
if (state) {
applyThemeClass(state.theme);
}
};
},
},
),
);
+65
View File
@@ -0,0 +1,65 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
export interface JmapRequest {
using: string[];
methodCalls: JmapMethodCall[];
}
export type JmapMethodCall = [string, Record<string, unknown>, string];
export interface JmapResponse {
methodResponses: JmapMethodResponse[];
sessionState?: string;
}
export type JmapMethodResponse = [string, Record<string, unknown>, string];
export interface JmapGetResponse {
accountId: string;
state: string;
list: Record<string, unknown>[];
notFound: string[];
}
export interface JmapQueryResponse {
accountId: string;
queryState: string;
canCalculateChanges: boolean;
position: number;
ids: string[];
total?: number;
limit?: number;
}
export interface JmapSetResponse {
accountId: string;
oldState: string;
newState: string;
created: Record<string, Record<string, unknown>> | null;
notCreated: Record<string, JmapSetError> | null;
updated: Record<string, null | Record<string, unknown>> | null;
notUpdated: Record<string, JmapSetError> | null;
destroyed: string[] | null;
notDestroyed: Record<string, JmapSetError> | null;
}
export interface JmapSetError {
type: string;
description?: string;
properties?: string[];
existingId?: string;
objectId?: string;
linkedObjects?: string[];
validationErrors?: ValidationError[];
}
export interface ValidationError {
type: 'Invalid' | 'Required' | 'MaxLength' | 'MinLength' | 'MaxValue' | 'MinValue';
property: string;
value?: unknown;
required?: number;
}
+393
View File
@@ -0,0 +1,393 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
import type { Dashboard } from '@/features/dashboard/types/schema';
export interface Schema {
objects: Record<string, ObjectType>;
schemas: Record<string, ObjectSchema>;
fields: Record<string, Fields>;
forms: Record<string, Form>;
lists: Record<string, List>;
enums: Record<string, EnumVariant[]>;
dashboards: Dashboard[];
layouts: Layout[];
}
export type ObjectType = ObjectTypeSingleton | ObjectTypeObject | ObjectTypeView;
export interface ObjectTypeSingleton {
type: 'singleton';
description: string;
permissionPrefix: string;
enterprise?: boolean;
}
export interface ObjectTypeObject {
type: 'object';
description: string;
permissionPrefix: string;
enterprise?: boolean;
}
export interface ObjectTypeView {
type: 'view';
objectName: string;
}
export type ObjectSchema = ObjectSchemaSingle | ObjectSchemaMultiple;
export interface ObjectSchemaSingle {
type: 'single';
schemaName: string;
}
export interface ObjectSchemaMultiple {
type: 'multiple';
variants: ObjectVariant[];
}
export interface ObjectVariant {
name: string;
label: string;
schemaName?: string;
}
export interface Fields {
properties: Record<string, Field>;
defaults?: Record<string, unknown>;
}
export interface Field {
description: string;
type: FieldType;
update: FieldUpdate;
enterprise?: boolean;
}
export type FieldUpdate = 'mutable' | 'immutable' | 'serverSet';
export type FieldType =
| FieldTypeString
| FieldTypeNumber
| FieldTypeUtcDateTime
| FieldTypeBoolean
| FieldTypeEnum
| FieldTypeBlobId
| FieldTypeObjectId
| FieldTypeObject
| FieldTypeObjectList
| FieldTypeSet
| FieldTypeMap;
export interface FieldTypeString {
type: 'string';
format: StringFormat;
minLength?: number;
maxLength?: number;
nullable?: boolean;
}
export interface FieldTypeNumber {
type: 'number';
format: NumberFormat;
min?: number;
max?: number;
nullable?: boolean;
}
export interface FieldTypeUtcDateTime {
type: 'utcDateTime';
nullable?: boolean;
}
export interface FieldTypeBoolean {
type: 'boolean';
}
export interface FieldTypeEnum {
type: 'enum';
enumName: string;
nullable?: boolean;
}
export interface FieldTypeBlobId {
type: 'blobId';
}
export interface FieldTypeObjectId {
type: 'objectId';
objectName: string;
nullable?: boolean;
}
export interface FieldTypeObject {
type: 'object';
objectName: string;
nullable?: boolean;
}
export interface FieldTypeObjectList {
type: 'objectList';
objectName: string;
minItems?: number;
maxItems?: number;
}
export interface FieldTypeSet {
type: 'set';
class: ScalarType;
minItems?: number;
maxItems?: number;
}
export interface FieldTypeMap {
type: 'map';
keyClass: ScalarType;
valueClass: MapValueType;
minItems?: number;
maxItems?: number;
}
export type ScalarType = ScalarTypeString | ScalarTypeObjectId | ScalarTypeEnum;
export interface ScalarTypeString {
type: 'string';
format: StringFormat;
minLength?: number;
maxLength?: number;
}
export interface ScalarTypeObjectId {
type: 'objectId';
objectName: string;
}
export interface ScalarTypeEnum {
type: 'enum';
enumName: string;
}
export type MapValueType = MapValueTypeString | MapValueTypeNumber | MapValueTypeEnum | MapValueTypeObject;
export interface MapValueTypeString {
type: 'string';
format: StringFormat;
minLength?: number;
maxLength?: number;
}
export interface MapValueTypeNumber {
type: 'number';
format: NumberFormat;
min?: number;
max?: number;
}
export interface MapValueTypeEnum {
type: 'enum';
enumName: string;
}
export interface MapValueTypeObject {
type: 'object';
objectName: string;
}
export type StringFormat =
| 'string'
| 'ipAddress'
| 'ipNetwork'
| 'socketAddress'
| 'emailAddress'
| 'secret'
| 'secretText'
| 'uri'
| 'color'
| 'text'
| 'html';
export type NumberFormat = 'integer' | 'unsignedInteger' | 'float' | 'size' | 'duration';
export interface EnumVariant {
name: string;
label: string;
explanation?: string;
color?: string;
}
export interface List {
title: string;
subtitle: string;
labelProperty?: string;
singularName: string;
pluralName: string;
columns: Column[];
filters?: Filter[];
filtersStatic?: Record<string, unknown>;
sort?: string[];
massActions?: MassAction[];
itemActions?: ItemAction[];
}
export interface Column {
name: string;
label: string;
}
export type MassAction = MassActionSetProperty | MassActionDelete | MassActionSeparator;
export interface MassActionSetProperty {
type: 'setProperty';
label: string;
properties: Record<string, unknown>;
}
export interface MassActionDelete {
type: 'delete';
label: string;
}
export interface MassActionSeparator {
type: 'separator';
}
export type ItemAction =
| ItemActionDelete
| ItemActionSetProperty
| ItemActionQuery
| ItemActionView
| ItemActionSeparator;
export interface ItemActionDelete {
type: 'delete';
label: string;
}
export interface ItemActionSetProperty {
type: 'setProperty';
label: string;
properties: Record<string, unknown>;
}
export interface ItemActionQuery {
type: 'query';
label: string;
objectName: string;
fieldName: string;
}
export interface ItemActionView {
type: 'view';
label: string;
objectName: string;
}
export interface ItemActionSeparator {
type: 'separator';
}
export type Filter = FilterText | FilterEnum | FilterInteger | FilterDate | FilterObjectId;
export interface FilterText {
type: 'text';
field: string;
label: string;
}
export interface FilterEnum {
type: 'enum';
field: string;
enumName: string;
label: string;
}
export interface FilterInteger {
type: 'integer';
field: string;
label: string;
}
export interface FilterDate {
type: 'date';
field: string;
label: string;
}
export interface FilterObjectId {
type: 'objectId';
field: string;
objectName: string;
label: string;
}
export interface Form {
title?: string;
subtitle?: string;
sections: FormSection[];
}
export interface FormSection {
title?: string;
fields: FormField[];
}
export interface FormField {
name: string;
label: string;
keyLabel?: string;
valueLabel?: string;
placeholder?: string;
}
export interface Layout {
name: string;
icon: string;
items: LayoutItem[];
}
export type LayoutItem = LayoutItemContainer | LayoutItemLink;
export interface LayoutItemContainer {
container: {
name: string;
icon: string;
items: LayoutSubItem[];
};
}
export interface LayoutItemLink {
link: {
name: string;
icon: string;
viewName: string;
};
}
export type LayoutSubItem = LayoutSubItemContainer | LayoutSubItemLink;
export interface LayoutSubItemContainer {
type: 'container';
name: string;
items: LayoutSubItem[];
}
export interface LayoutSubItemLink {
type: 'link';
name: string;
viewName: string;
}
export type {
Dashboard,
Card,
Chart,
Series,
Aggregate,
ChartKind,
CardSource,
MetricFormat,
} from '@/features/dashboard/types/schema';
+20
View File
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_OAUTH_CLIENT_ID: string;
readonly VITE_ACCESS_TOKEN: string;
readonly VITE_OAUTH_SCOPES: string;
readonly VITE_DEBUG_JMAP?: string;
readonly VITE_DEBUG_FORMS?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}