Initial commit
This commit is contained in:
+18
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
@@ -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">—</span>;
|
||||
default:
|
||||
return <>{JSON.stringify(value)}</>;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(' ');
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
},
|
||||
}));
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: {} });
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
Vendored
+20
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user