From d7f3106116e10ee400af1db14503a79470cbc92a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 14:51:02 -0700 Subject: [PATCH] Command palette: run actions, not just find pages Ctrl+K now offers commands beside pages and settings (admin UX roadmap item 8): - Server actions the account may run. Choosing one opens the Actions page with it picked: an action with options shows its form, and one without asks before it runs, since a click on the Actions page runs it at once. - The guided setups (sending, limits, sign-in directory, certificates), each through its own "Guided or manual?". - A delivery trace for an address or domain typed in, for whoever may run one. - Theme and sign out. Sign-out is shared with the account menu. The highlighted row goes back to the first result whenever the results change; cmdk kept the old row's value, so after a new query Enter could pick nothing. 16 new English strings and a reworded search placeholder; no other catalogues yet. --- src/components/common/CommandPalette.tsx | 211 ++++++++++++++---- src/components/layout/TopBar.tsx | 19 +- src/features/actions/ActionPage.tsx | 62 ++++- src/features/commands/commands.test.ts | 127 +++++++++++ src/features/commands/commands.ts | 186 +++++++++++++++ .../troubleshoot/DeliveryTracePage.tsx | 13 +- src/features/troubleshoot/target.ts | 16 ++ src/i18n/en.json | 23 +- src/services/auth/signOut.ts | 19 ++ 9 files changed, 599 insertions(+), 77 deletions(-) create mode 100644 src/features/commands/commands.test.ts create mode 100644 src/features/commands/commands.ts create mode 100644 src/features/troubleshoot/target.ts create mode 100644 src/services/auth/signOut.ts diff --git a/src/components/common/CommandPalette.tsx b/src/components/common/CommandPalette.tsx index c782f16..b6074ea 100644 --- a/src/components/common/CommandPalette.tsx +++ b/src/components/common/CommandPalette.tsx @@ -2,14 +2,34 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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 = { + 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(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 = useMemo( () => ({ @@ -35,53 +120,81 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) { }, [open, reset]); return ( - - - {t('globalSearch.title', 'Search')} - - - ESC - - } - /> - - - {debouncedQuery.trim() - ? t('globalSearch.noResults', 'No results found.') - : t('globalSearch.typeToSearch', 'Type to search the admin panel.')} - - {Array.from(groups.entries()).map(([type, entries]) => ( - - {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}`; + <> + + + {t('globalSearch.title', 'Search')} + + + ESC + + } + /> + + + {debouncedQuery.trim() + ? t('globalSearch.noResults', 'No results found.') + : t('globalSearch.typeToSearch', 'Type to search the admin panel.')} + + {commands.length > 0 && ( + + {commands.map((command) => { + const Icon = COMMAND_ICONS[command.icon]; + return ( + runCommand(command)} + > + +
+ {command.label} + {command.hint} +
+
+ ); + })} +
+ )} + {Array.from(groups.entries()).map(([type, entries]) => ( + + {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 ( - selectEntry(entry)}> - -
- {friendlyName(entry.text)} - {entry.breadcrumb} -
- {actionLabel} -
- ); - })} -
- ))} -
-
-
-
+ return ( + selectEntry(entry)}> + +
+ {friendlyName(entry.text)} + {entry.breadcrumb} +
+ {actionLabel} +
+ ); + })} +
+ ))} +
+
+
+
+ + + + + ); } diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx index 43a9527..72dc7e0 100644 --- a/src/components/layout/TopBar.tsx +++ b/src/components/layout/TopBar.tsx @@ -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" > - {t('globalSearch.placeholder', 'Search pages, fields, settings...')} + + {t('globalSearch.placeholderActions', 'Search pages and settings, or run an action...')} + {IS_MAC ? '⌘K' : 'Ctrl K'} @@ -293,17 +294,7 @@ export function TopBar() { - { - const endSessionEndpoint = useAuthStore.getState().endSessionEndpoint; - logout(); - if (endSessionEndpoint) { - window.location.href = buildEndSessionUrl(endSessionEndpoint, getPostLogoutRedirectUri()); - } else { - navigate('/login'); - } - }} - > + signOut(navigate)}> {t('logout', 'Logout')} diff --git a/src/features/actions/ActionPage.tsx b/src/features/actions/ActionPage.tsx index 012f845..7d7e24c 100644 --- a/src/features/actions/ActionPage.tsx +++ b/src/features/actions/ActionPage.tsx @@ -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 } | { 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({ 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=` 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(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 ( +
+ + + + + {state.variant.label} + + + +

+ {t('actions.confirmRun', 'This runs on the server as soon as you choose Run. It has no options.')} +

+
+ + +
+
+
+
+ ); + } + if (state.kind === 'pick') { return (
diff --git a/src/features/commands/commands.test.ts b/src/features/commands/commands.test.ts new file mode 100644 index 0000000..7253700 --- /dev/null +++ b/src/features/commands/commands.test.ts @@ -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 { + 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)?.[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, 'someone@example.org', ctx); + expect(found[0]).toMatchObject({ + id: 'trace', + label: 'Trace delivery to someone@example.org', + 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([]); + }); +}); diff --git a/src/features/commands/commands.ts b/src/features/commands/commands.ts new file mode 100644 index 0000000..bfcd386 --- /dev/null +++ b/src/features/commands/commands.ts @@ -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; + +export interface CommandContext { + schema: Schema; + searchIndex: SearchIndexEntry[]; + viewToSection: Record; + 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; +} diff --git a/src/features/troubleshoot/DeliveryTracePage.tsx b/src/features/troubleshoot/DeliveryTracePage.tsx index 249c847..efe3b69 100644 --- a/src/features/troubleshoot/DeliveryTracePage.tsx +++ b/src/features/troubleshoot/DeliveryTracePage.tsx @@ -2,10 +2,13 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * 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(); diff --git a/src/features/troubleshoot/target.ts b/src/features/troubleshoot/target.ts new file mode 100644 index 0000000..df5e81d --- /dev/null +++ b/src/features/troubleshoot/target.ts @@ -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('.'); +} diff --git a/src/i18n/en.json b/src/i18n/en.json index 03c89db..5459f9e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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" } } diff --git a/src/services/auth/signOut.ts b/src/services/auth/signOut.ts new file mode 100644 index 0000000..cb2862e --- /dev/null +++ b/src/services/auth/signOut.ts @@ -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'); + } +} -- 2.54.0