Command palette: run actions, not just find pages #78

Merged
jcoffey-dev merged 1 commits from feat/palette-actions into main 2026-10-05 21:55:59 +00:00
9 changed files with 599 additions and 77 deletions

No files matched your search

+162 -49
View File
@@ -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')} />
</>
);
}
+5 -14
View File
@@ -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>
+61 -1
View File
@@ -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">
+127
View File
@@ -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([]);
});
});
+186
View File
@@ -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();
+16
View File
@@ -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
View File
@@ -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"
}
}
+19
View File
@@ -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');
}
}