Command palette: run actions, not just find pages #78
No files matched your search
@@ -2,14 +2,34 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LogOut, Route, SunMoon, Wand2, Zap } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { friendlyName, getActionInfo, getObjectKind, useGlobalSearch } from '@/hooks/useGlobalSearch';
|
||||
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
||||
import { useSchemaStore, type SearchIndexEntry } from '@/stores/schemaStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { signOut } from '@/services/auth/signOut';
|
||||
import { buildCommands, matchCommands, type LaunchJob, type PaletteCommand } from '@/features/commands/commands';
|
||||
import { SendingLaunchChoice } from '@/features/sending/SendingSetupCard';
|
||||
import { LimitsLaunchChoice } from '@/features/limits/LimitsSummary';
|
||||
import { DirectoryLaunchChoice } from '@/features/directory/DirectorySetupCard';
|
||||
import { CertificateLaunchChoice } from '@/features/certificates/CertificateSetupCard';
|
||||
|
||||
const COMMAND_ICONS: Record<PaletteCommand['icon'], typeof Zap> = {
|
||||
action: Zap,
|
||||
guide: Wand2,
|
||||
trace: Route,
|
||||
theme: SunMoon,
|
||||
signOut: LogOut,
|
||||
};
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
@@ -20,6 +40,71 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
const closePalette = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||
const { query, setQuery, debouncedQuery, groups, selectEntry, reset, schema } = useGlobalSearch(closePalette);
|
||||
const navigate = useNavigate();
|
||||
const searchIndex = useSchemaStore((s) => s.searchIndex);
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const theme = useUIStore((s) => s.theme);
|
||||
const toggleTheme = useUIStore((s) => s.toggleTheme);
|
||||
// inbuxa: a guided setup chosen here still asks "Guided or manual?" first.
|
||||
const [launch, setLaunch] = useState<LaunchJob | null>(null);
|
||||
|
||||
// inbuxa: commands (server actions, guided setups, a delivery trace, theme, sign out) beside the pages.
|
||||
const commandContext = useMemo(
|
||||
() => (schema ? { schema, searchIndex, viewToSection, hasPermission, hasObjectPermission, theme, t } : null),
|
||||
[schema, searchIndex, viewToSection, hasPermission, hasObjectPermission, theme, t],
|
||||
);
|
||||
const allCommands = useMemo(() => (commandContext ? buildCommands(commandContext) : []), [commandContext]);
|
||||
const commands = useMemo(
|
||||
() => (commandContext ? matchCommands(allCommands, debouncedQuery, commandContext) : []),
|
||||
[allCommands, debouncedQuery, commandContext],
|
||||
);
|
||||
|
||||
// inbuxa: the highlighted row, back on the first result whenever the results
|
||||
// change. Left to itself, cmdk keeps the old row's value after a new query,
|
||||
// and once that row is gone Enter picks nothing.
|
||||
const rowValues = useMemo(() => {
|
||||
const values = commands.map((c) => `command-${c.id}`);
|
||||
for (const [type, entries] of groups) {
|
||||
entries.forEach((entry, idx) => values.push(`${type}-${idx}-${entry.viewName}`));
|
||||
}
|
||||
return values;
|
||||
}, [commands, groups]);
|
||||
const rowsKey = rowValues.join('\n');
|
||||
const [selected, setSelected] = useState(rowValues[0] ?? '');
|
||||
const [selectedFor, setSelectedFor] = useState(rowsKey);
|
||||
if (selectedFor !== rowsKey) {
|
||||
setSelectedFor(rowsKey);
|
||||
setSelected(rowValues[0] ?? '');
|
||||
}
|
||||
|
||||
const runCommand = useCallback(
|
||||
(command: PaletteCommand) => {
|
||||
closePalette();
|
||||
switch (command.kind.type) {
|
||||
case 'navigate':
|
||||
navigate(command.kind.to);
|
||||
break;
|
||||
case 'launch':
|
||||
setLaunch(command.kind.job);
|
||||
break;
|
||||
case 'theme':
|
||||
toggleTheme();
|
||||
break;
|
||||
case 'signOut':
|
||||
signOut(navigate);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[closePalette, navigate, toggleTheme],
|
||||
);
|
||||
const launchProps = (job: LaunchJob) => ({
|
||||
open: launch === job,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) setLaunch(null);
|
||||
},
|
||||
});
|
||||
|
||||
const groupLabels: Record<SearchIndexEntry['type'], string> = useMemo(
|
||||
() => ({
|
||||
@@ -35,53 +120,81 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||
}, [open, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
loop
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={t('globalSearch.placeholder', 'Search pages, fields, settings...')}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
trailing={
|
||||
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{debouncedQuery.trim()
|
||||
? t('globalSearch.noResults', 'No results found.')
|
||||
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
||||
</CommandEmpty>
|
||||
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||
<CommandGroup key={type} heading={groupLabels[type]}>
|
||||
{entries.map((entry, idx) => {
|
||||
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
||||
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
||||
const itemValue = `${type}-${idx}-${entry.viewName}`;
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="top-[15%] translate-y-0 overflow-hidden p-0" showCloseButton={false}>
|
||||
<DialogTitle className="sr-only">{t('globalSearch.title', 'Search')}</DialogTitle>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
value={selected}
|
||||
onValueChange={setSelected}
|
||||
loop
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={t('globalSearch.placeholderActions', 'Search pages and settings, or run an action...')}
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
trailing={
|
||||
<kbd className="pointer-events-none ml-2 inline-flex h-5 shrink-0 select-none items-center rounded-md border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
ESC
|
||||
</kbd>
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{debouncedQuery.trim()
|
||||
? t('globalSearch.noResults', 'No results found.')
|
||||
: t('globalSearch.typeToSearch', 'Type to search the admin panel.')}
|
||||
</CommandEmpty>
|
||||
{commands.length > 0 && (
|
||||
<CommandGroup heading={t('globalSearch.actions', 'Actions')}>
|
||||
{commands.map((command) => {
|
||||
const Icon = COMMAND_ICONS[command.icon];
|
||||
return (
|
||||
<CommandItem
|
||||
key={command.id}
|
||||
value={`command-${command.id}`}
|
||||
onSelect={() => runCommand(command)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<span className="truncate font-medium">{command.label}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{command.hint}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{Array.from(groups.entries()).map(([type, entries]) => (
|
||||
<CommandGroup key={type} heading={groupLabels[type]}>
|
||||
{entries.map((entry, idx) => {
|
||||
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
|
||||
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
|
||||
const itemValue = `${type}-${idx}-${entry.viewName}`;
|
||||
|
||||
return (
|
||||
<CommandItem key={itemValue} value={itemValue} onSelect={() => selectEntry(entry)}>
|
||||
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
||||
</div>
|
||||
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
return (
|
||||
<CommandItem key={itemValue} value={itemValue} onSelect={() => selectEntry(entry)}>
|
||||
<ActionIcon className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<span className="truncate font-medium">{friendlyName(entry.text)}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
|
||||
</div>
|
||||
<span className="ml-auto shrink-0 pl-2 text-xs text-muted-foreground">{actionLabel}</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<SendingLaunchChoice {...launchProps('sending')} />
|
||||
<LimitsLaunchChoice {...launchProps('limits')} />
|
||||
<DirectoryLaunchChoice {...launchProps('directory')} />
|
||||
<CertificateLaunchChoice {...launchProps('certificates')} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import { sectionLandingLink } from '@/lib/lastVisited';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isAdminLayout, useUIStore } from '@/stores/uiStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from '@/services/auth/oauth';
|
||||
import { signOut } from '@/services/auth/signOut';
|
||||
import { createElement, useEffect, useState } from 'react';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
@@ -67,7 +67,6 @@ export function TopBar() {
|
||||
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);
|
||||
@@ -116,7 +115,9 @@ export function TopBar() {
|
||||
className="flex h-9 w-full max-w-md items-center gap-2 rounded-md border border-input bg-transparent px-3 text-sm text-muted-foreground shadow-sm transition-colors hover:bg-accent"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
<span className="flex-1 text-left">{t('globalSearch.placeholder', 'Search pages, fields, settings...')}</span>
|
||||
<span className="flex-1 text-left">
|
||||
{t('globalSearch.placeholderActions', 'Search pages and settings, or run an action...')}
|
||||
</span>
|
||||
<kbd className="pointer-events-none flex h-5 select-none items-center rounded border bg-muted px-1.5 font-mono text-[10px] font-medium">
|
||||
{IS_MAC ? '⌘K' : 'Ctrl K'}
|
||||
</kbd>
|
||||
@@ -293,17 +294,7 @@ export function TopBar() {
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
|
||||
logout();
|
||||
if (endSessionEndpoint) {
|
||||
window.location.href = buildEndSessionUrl(endSessionEndpoint, getPostLogoutRedirectUri());
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => signOut(navigate)}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t('logout', 'Logout')}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Zap, Loader2, ChevronRight, CircleCheck, CircleAlert } from 'lucide-react';
|
||||
import i18n from '@/i18n';
|
||||
@@ -26,6 +27,8 @@ import { spamVerdictSubject } from '@/features/ai/explain/explain';
|
||||
|
||||
type ActionState =
|
||||
| { kind: 'pick' }
|
||||
// inbuxa: an action without options, chosen from the command palette, waits to be confirmed.
|
||||
| { kind: 'confirm'; variant: ObjectVariant }
|
||||
| { kind: 'form'; variant: ObjectVariant; formData: Record<string, unknown> }
|
||||
| { kind: 'submitting'; variant: ObjectVariant }
|
||||
| {
|
||||
@@ -46,6 +49,7 @@ export function ActionPage({ viewName }: ActionPageProps) {
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const [state, setState] = useState<ActionState>({ kind: 'pick' });
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
if (!schema) return null;
|
||||
@@ -155,8 +159,64 @@ export function ActionPage({ viewName }: ActionPageProps) {
|
||||
[schema, resolved, t],
|
||||
);
|
||||
|
||||
// inbuxa: `?action=<name>` from the command palette opens that action. One
|
||||
// with options shows its form; one without asks first, since here a click
|
||||
// on it runs it at once. Read while rendering, so it also works when the
|
||||
// palette is used on this page; the effect then takes it out of the address.
|
||||
const requested = searchParams.get('action');
|
||||
const [openedFromPalette, setOpenedFromPalette] = useState<string | null>(null);
|
||||
if (requested !== openedFromPalette) {
|
||||
setOpenedFromPalette(requested);
|
||||
const variant = requested && sch?.type === 'multiple' ? sch.variants.find((v) => v.name === requested) : undefined;
|
||||
if (schema && variant && canCreate && hasPermission(`action${variant.name}`)) {
|
||||
if (variant.schemaName) {
|
||||
const defaults = schema.fields[variant.schemaName]?.defaults;
|
||||
setState({ kind: 'form', variant, formData: defaults ? { ...defaults } : {} });
|
||||
} else {
|
||||
setState({ kind: 'confirm', variant });
|
||||
}
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!requested) return;
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('action');
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [requested, setSearchParams]);
|
||||
|
||||
if (!schema || !resolved || !sch) return null;
|
||||
|
||||
if (state.kind === 'confirm') {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl space-y-4 pt-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
{state.variant.label}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm">
|
||||
{t('actions.confirmRun', 'This runs on the server as soon as you choose Run. It has no options.')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => handlePickVariant(state.variant)}>{t('actions.run', 'Run')}</Button>
|
||||
<Button variant="outline" onClick={() => setState({ kind: 'pick' })}>
|
||||
{t('actions.backToActions', 'Back to actions')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.kind === 'pick') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { Schema } from '@/types/schema';
|
||||
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
||||
import { buildCommands, matchCommands, type CommandContext } from './commands';
|
||||
|
||||
const schema = {
|
||||
objects: {
|
||||
'x:Action': { type: 'object', description: '', permissionPrefix: 'sysAction' },
|
||||
'x:MtaOutboundStrategy': { type: 'singleton', description: '', permissionPrefix: 'sysMtaOutboundStrategy' },
|
||||
'x:Directory': { type: 'object', description: '', permissionPrefix: 'sysDirectory' },
|
||||
},
|
||||
schemas: {
|
||||
'x:Action': {
|
||||
type: 'multiple',
|
||||
variants: [
|
||||
{ name: 'ReloadSettings', label: 'Reload: Server settings' },
|
||||
{ name: 'PauseMtaQueue', label: 'MTA: Pause queue processing' },
|
||||
{ name: 'ClassifySpam', label: 'Spam Filter: Classify a message', schemaName: 'x:SpamClassify' },
|
||||
],
|
||||
},
|
||||
},
|
||||
fields: {},
|
||||
forms: {},
|
||||
lists: {},
|
||||
enums: {},
|
||||
dashboards: [],
|
||||
layouts: [],
|
||||
} as unknown as Schema;
|
||||
|
||||
const searchIndex: SearchIndexEntry[] = [
|
||||
{ text: 'Actions', type: 'link', viewName: 'x:Action', section: 'Management', breadcrumb: 'Management' },
|
||||
];
|
||||
|
||||
function context(over: Partial<CommandContext> = {}): CommandContext {
|
||||
return {
|
||||
schema,
|
||||
searchIndex,
|
||||
viewToSection: { 'CustomComponent/LiveDelivery': 'Management' },
|
||||
hasPermission: () => true,
|
||||
hasObjectPermission: () => true,
|
||||
theme: 'light',
|
||||
t: (_key, fallback, options) =>
|
||||
fallback.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String((options as Record<string, unknown>)?.[k] ?? '')),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (cs: { id: string }[]) => cs.map((c) => c.id);
|
||||
|
||||
describe('buildCommands', () => {
|
||||
it('offers each server action, opening the Actions page with it picked', () => {
|
||||
const commands = buildCommands(context());
|
||||
const pause = commands.find((c) => c.id === 'action:PauseMtaQueue');
|
||||
expect(pause?.label).toBe('MTA: Pause queue processing');
|
||||
expect(pause?.hint).toBe('Server action');
|
||||
expect(pause?.kind).toEqual({ type: 'navigate', to: '/Management/x:Action?action=PauseMtaQueue' });
|
||||
});
|
||||
|
||||
it('leaves out actions the account may not run', () => {
|
||||
const commands = buildCommands(context({ hasPermission: (p) => p !== 'actionPauseMtaQueue' }));
|
||||
expect(ids(commands)).not.toContain('action:PauseMtaQueue');
|
||||
expect(ids(commands)).toContain('action:ReloadSettings');
|
||||
});
|
||||
|
||||
it('offers no server action without permission to create one', () => {
|
||||
const commands = buildCommands(context({ hasObjectPermission: (prefix) => prefix !== 'sysAction' }));
|
||||
expect(ids(commands).filter((id) => id.startsWith('action:'))).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers a guided setup only to whoever may change what it sets up', () => {
|
||||
const commands = buildCommands(
|
||||
context({ hasObjectPermission: (prefix, action) => !(prefix === 'sysDirectory' && action === 'Update') }),
|
||||
);
|
||||
expect(ids(commands)).toContain('guide:sending');
|
||||
expect(ids(commands)).not.toContain('guide:directory');
|
||||
// Not in this schema at all, so never offered.
|
||||
expect(ids(commands)).not.toContain('guide:limits');
|
||||
});
|
||||
|
||||
it('names the theme it switches to', () => {
|
||||
expect(buildCommands(context()).find((c) => c.id === 'app:theme')?.label).toBe('Switch to the dark theme');
|
||||
expect(buildCommands(context({ theme: 'dark' })).find((c) => c.id === 'app:theme')?.label).toBe(
|
||||
'Switch to the light theme',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchCommands', () => {
|
||||
const ctx = context();
|
||||
const all = buildCommands(ctx);
|
||||
|
||||
it('needs every word typed', () => {
|
||||
expect(ids(matchCommands(all, 'pause queue', ctx))).toEqual(['action:PauseMtaQueue']);
|
||||
expect(ids(matchCommands(all, 'pause settings', ctx))).toEqual([]);
|
||||
});
|
||||
|
||||
it('matches keywords as well as labels', () => {
|
||||
expect(ids(matchCommands(all, 'ldap', ctx))).toEqual(['guide:directory']);
|
||||
expect(ids(matchCommands(all, 'logout', ctx))).toEqual(['app:signOut']);
|
||||
});
|
||||
|
||||
it('offers nothing for an empty query', () => {
|
||||
expect(matchCommands(all, ' ', ctx)).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers a delivery trace for an address or a domain, first', () => {
|
||||
const found = matchCommands(all, '[email protected]', ctx);
|
||||
expect(found[0]).toMatchObject({
|
||||
id: 'trace',
|
||||
label: 'Trace delivery to [email protected]',
|
||||
kind: { type: 'navigate', to: '/Management/CustomComponent/LiveDelivery?target=someone%40example.org' },
|
||||
});
|
||||
expect(ids(matchCommands(all, 'example.org', ctx))).toEqual(['trace']);
|
||||
});
|
||||
|
||||
it('offers no trace for a plain word, or without permission to run one', () => {
|
||||
expect(ids(matchCommands(all, 'reload', ctx))).not.toContain('trace');
|
||||
const denied = context({ hasPermission: (p) => p !== 'liveDeliveryTest' });
|
||||
expect(ids(matchCommands(buildCommands(denied), 'example.org', denied))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the command palette can do, beside finding a page: run a server
|
||||
* action, start a guided setup, trace a delivery, switch the theme, sign
|
||||
* out. Each command is offered only to someone allowed to carry it out.
|
||||
*
|
||||
* Nothing here runs a server action. Choosing one opens the Actions page
|
||||
* with it picked, where an action with options shows its form and one
|
||||
* without asks before it runs (see `ActionPage`'s `?action=`).
|
||||
*/
|
||||
import type { Schema } from '@/types/schema';
|
||||
import type { SearchIndexEntry } from '@/stores/schemaStore';
|
||||
import { resolveObject, resolveSchema } from '@/lib/schemaResolver';
|
||||
import { isValidTarget } from '@/features/troubleshoot/target';
|
||||
|
||||
/** The guided setups the palette can start, each through its own "Guided or manual?". */
|
||||
export type LaunchJob = 'sending' | 'limits' | 'directory' | 'certificates';
|
||||
|
||||
export type CommandKind =
|
||||
{ type: 'navigate'; to: string } | { type: 'launch'; job: LaunchJob } | { type: 'theme' } | { type: 'signOut' };
|
||||
|
||||
export interface PaletteCommand {
|
||||
id: string;
|
||||
label: string;
|
||||
/** A second line: the group an action belongs to, or what the command will do. */
|
||||
hint: string;
|
||||
icon: 'action' | 'guide' | 'trace' | 'theme' | 'signOut';
|
||||
keywords: string[];
|
||||
kind: CommandKind;
|
||||
}
|
||||
|
||||
type T = (key: string, fallback: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export interface CommandContext {
|
||||
schema: Schema;
|
||||
searchIndex: SearchIndexEntry[];
|
||||
viewToSection: Record<string, string>;
|
||||
hasPermission: (perm: string) => boolean;
|
||||
hasObjectPermission: (prefix: string, action: 'Get' | 'Query' | 'Create' | 'Update' | 'Destroy') => boolean;
|
||||
theme: 'light' | 'dark';
|
||||
t: T;
|
||||
}
|
||||
|
||||
/** The page that lists server actions, as the layouts reach it. */
|
||||
function actionsPage(ctx: CommandContext): { section: string; viewName: string } | null {
|
||||
for (const entry of ctx.searchIndex) {
|
||||
if (entry.type !== 'link') continue;
|
||||
if (resolveObject(ctx.schema, entry.viewName)?.objectName === 'x:Action') {
|
||||
return { section: entry.section, viewName: entry.viewName };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function serverActions(ctx: CommandContext): PaletteCommand[] {
|
||||
const page = actionsPage(ctx);
|
||||
if (!page) return [];
|
||||
const resolved = resolveObject(ctx.schema, page.viewName);
|
||||
if (!resolved || !ctx.hasObjectPermission(resolved.permissionPrefix, 'Create')) return [];
|
||||
const sch = resolveSchema(ctx.schema, resolved.objectName);
|
||||
if (sch?.type !== 'multiple') return [];
|
||||
const out: PaletteCommand[] = [];
|
||||
for (const v of sch.variants) {
|
||||
if (!ctx.hasPermission(`action${v.name}`)) continue;
|
||||
// The whole label, "Group: Action": in a list beside pages, "Server
|
||||
// settings" alone reads like the page, where "Reload: Server settings" doesn't.
|
||||
out.push({
|
||||
id: `action:${v.name}`,
|
||||
label: v.label,
|
||||
hint: ctx.t('commands.actionHint', 'Server action'),
|
||||
icon: 'action',
|
||||
keywords: [v.name, 'action', 'run'],
|
||||
kind: {
|
||||
type: 'navigate',
|
||||
to: `/${page.section}/${page.viewName}?action=${encodeURIComponent(v.name)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Each guided setup, offered to whoever may change what it sets up. */
|
||||
const GUIDES: { job: LaunchJob; object: string; label: (t: T) => string; keywords: string[] }[] = [
|
||||
{
|
||||
job: 'sending',
|
||||
object: 'x:MtaOutboundStrategy',
|
||||
label: (t) => t('commands.guideSending', 'Set up how this server sends mail'),
|
||||
keywords: ['relay', 'smarthost', 'port 25', 'outbound', 'delivery'],
|
||||
},
|
||||
{
|
||||
job: 'limits',
|
||||
object: 'x:MtaInboundThrottle',
|
||||
label: (t) => t('commands.guideLimits', 'Set up sending and receiving limits'),
|
||||
keywords: ['rate', 'throttle', 'quota'],
|
||||
},
|
||||
{
|
||||
job: 'directory',
|
||||
object: 'x:Directory',
|
||||
label: (t) => t('commands.guideDirectory', 'Connect a sign-in directory'),
|
||||
keywords: ['ldap', 'sql', 'oidc', 'active directory', 'sso'],
|
||||
},
|
||||
{
|
||||
job: 'certificates',
|
||||
object: 'x:Certificate',
|
||||
label: (t) => t('commands.guideCertificates', 'Set up automatic certificates'),
|
||||
keywords: ['acme', "let's encrypt", 'letsencrypt', 'tls', 'https'],
|
||||
},
|
||||
];
|
||||
|
||||
function guides(ctx: CommandContext): PaletteCommand[] {
|
||||
const hint = ctx.t('commands.guideHint', 'Guided or manual, you choose next');
|
||||
return GUIDES.filter((g) => {
|
||||
const resolved = resolveObject(ctx.schema, g.object);
|
||||
return resolved ? ctx.hasObjectPermission(resolved.permissionPrefix, 'Update') : false;
|
||||
}).map((g) => ({
|
||||
id: `guide:${g.job}`,
|
||||
label: g.label(ctx.t),
|
||||
hint,
|
||||
icon: 'guide' as const,
|
||||
keywords: [...g.keywords, 'guide', 'wizard', 'setup'],
|
||||
kind: { type: 'launch' as const, job: g.job },
|
||||
}));
|
||||
}
|
||||
|
||||
function appCommands(ctx: CommandContext): PaletteCommand[] {
|
||||
return [
|
||||
{
|
||||
id: 'app:theme',
|
||||
label:
|
||||
ctx.theme === 'dark'
|
||||
? ctx.t('commands.themeLight', 'Switch to the light theme')
|
||||
: ctx.t('commands.themeDark', 'Switch to the dark theme'),
|
||||
hint: ctx.t('commands.appHint', 'This browser'),
|
||||
icon: 'theme',
|
||||
keywords: ['theme', 'dark', 'light', 'mode', 'appearance'],
|
||||
kind: { type: 'theme' },
|
||||
},
|
||||
{
|
||||
id: 'app:signOut',
|
||||
label: ctx.t('commands.signOut', 'Sign out'),
|
||||
hint: ctx.t('commands.appHint', 'This browser'),
|
||||
icon: 'signOut',
|
||||
keywords: ['logout', 'log out', 'sign off'],
|
||||
kind: { type: 'signOut' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Every command that doesn't depend on what was typed. */
|
||||
export function buildCommands(ctx: CommandContext): PaletteCommand[] {
|
||||
return [...serverActions(ctx), ...guides(ctx), ...appCommands(ctx)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The commands that match a query: every word typed must appear in the label,
|
||||
* the hint or a keyword. An address or domain also offers a delivery trace to
|
||||
* it, for whoever may run one.
|
||||
*/
|
||||
export function matchCommands(commands: PaletteCommand[], query: string, ctx: CommandContext): PaletteCommand[] {
|
||||
const q = query.trim();
|
||||
const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length === 0) return [];
|
||||
const matched = commands.filter((c) => {
|
||||
const haystack = [c.label, c.hint, ...c.keywords].join(' ').toLowerCase();
|
||||
return tokens.every((tok) => haystack.includes(tok));
|
||||
});
|
||||
if (tokens.length === 1 && isValidTarget(q) && ctx.hasPermission('liveDeliveryTest')) {
|
||||
const section = ctx.viewToSection['CustomComponent/LiveDelivery'];
|
||||
if (section) {
|
||||
matched.unshift({
|
||||
id: 'trace',
|
||||
label: ctx.t('commands.trace', 'Trace delivery to {{target}}', { target: q }),
|
||||
hint: ctx.t('commands.traceHint', 'Opens the delivery test with this filled in'),
|
||||
icon: 'trace',
|
||||
keywords: [],
|
||||
kind: { type: 'navigate', to: `/${section}/CustomComponent/LiveDelivery?target=${encodeURIComponent(q)}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { isValidTarget } from './target';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Play,
|
||||
@@ -326,16 +329,6 @@ function useRelativeTime(startedAtMs: number | null): string {
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/** What a delivery trace accepts: an email address or a domain. Shared with the command palette. */
|
||||
export 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('.');
|
||||
}
|
||||
+20
-3
@@ -10,7 +10,9 @@
|
||||
"failedTitle": "Action failed",
|
||||
"noAdditionalData": "No additional data returned.",
|
||||
"otherGroup": "Other",
|
||||
"title": "Actions"
|
||||
"title": "Actions",
|
||||
"confirmRun": "This runs on the server as soon as you choose Run. It has no options.",
|
||||
"run": "Run"
|
||||
},
|
||||
"bootstrap": {
|
||||
"clipboardBlocked": "Your browser blocked clipboard access.",
|
||||
@@ -242,10 +244,11 @@
|
||||
"list": "List",
|
||||
"noResults": "No results found.",
|
||||
"pages": "Pages",
|
||||
"placeholder": "Search pages, fields, settings...",
|
||||
"settings": "Settings",
|
||||
"title": "Search",
|
||||
"typeToSearch": "Type to search the admin panel."
|
||||
"typeToSearch": "Type to search the admin panel.",
|
||||
"placeholderActions": "Search pages and settings, or run an action...",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"jmapErrors": {
|
||||
"addressBookHasContents": "This address book has contacts. Remove them first.",
|
||||
@@ -1949,5 +1952,19 @@
|
||||
"now": "now",
|
||||
"avgZone": "average · {{zone}}",
|
||||
"average": "average"
|
||||
},
|
||||
"commands": {
|
||||
"actionHint": "Server action",
|
||||
"guideHint": "Guided or manual, you choose next",
|
||||
"themeLight": "Switch to the light theme",
|
||||
"themeDark": "Switch to the dark theme",
|
||||
"appHint": "This browser",
|
||||
"signOut": "Sign out",
|
||||
"trace": "Trace delivery to {{target}}",
|
||||
"traceHint": "Opens the delivery test with this filled in",
|
||||
"guideSending": "Set up how this server sends mail",
|
||||
"guideLimits": "Set up sending and receiving limits",
|
||||
"guideDirectory": "Connect a sign-in directory",
|
||||
"guideCertificates": "Set up automatic certificates"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { buildEndSessionUrl, getPostLogoutRedirectUri } from './oauth';
|
||||
|
||||
/** Sign out here, then at the identity provider when it has an end-session endpoint. Used by the account menu and the command palette. */
|
||||
export function signOut(navigate: (to: string) => void): void {
|
||||
const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint;
|
||||
useAuthStore.getState().logout();
|
||||
if (endSessionEndpoint) {
|
||||
window.location.href = buildEndSessionUrl(endSessionEndpoint, getPostLogoutRedirectUri());
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user