fix(sidebar): require admin role for Schedule task and debounce search input (#1243)

The right-click Schedule task menu item and its keyboard shortcut were gated
only on isPaid, but the backend write routes under /api/scheduled-tasks
enforce requireAdmin + requirePaid on every action. Non-admin Skipper or
Admiral users would see the menu item and hit a 403 on click. The frontend
now mirrors the backend by gating Schedule task on isPaid && isAdmin so the
affordance only renders for users whose action will actually succeed.

Also adds a 120ms keystroke debounce to the sidebar search input. The
useStackListState filter rebuild was previously running on every keystroke
because <Command shouldFilter={false}> disables cmdk's own filter and the
existing 250ms timer only debounces state-invalidate events. Visible input
stays immediate via local state; the debounced emit drives the filter pass.

Adds a regression guard that /api/stacks/statuses is short-circuited by the
remote-node proxy (covers the sidebar status poll path) and updates the
sidebar feature docs to reflect the admin role requirement on Schedule task.
This commit is contained in:
Anso
2026-05-28 14:16:46 -04:00
committed by GitHub
parent 265fece988
commit 979181875d
9 changed files with 164 additions and 7 deletions
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act, fireEvent, cleanup } from '@testing-library/react';
import { Command } from '@/components/ui/command';
import { SidebarSearch } from '../SidebarSearch';
function renderInsideCommand(props: { value: string; onValueChange: (v: string) => void }) {
return render(
<Command shouldFilter={false}>
<SidebarSearch {...props} />
</Command>,
);
}
describe('SidebarSearch', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
it('reflects typing immediately in the input but does not emit until the debounce window closes', () => {
const onValueChange = vi.fn();
const { getByPlaceholderText } = renderInsideCommand({ value: '', onValueChange });
const input = getByPlaceholderText('Search stacks...') as HTMLInputElement;
act(() => {
fireEvent.input(input, { target: { value: 'n' } });
fireEvent.input(input, { target: { value: 'ng' } });
fireEvent.input(input, { target: { value: 'ngi' } });
fireEvent.input(input, { target: { value: 'ngin' } });
fireEvent.input(input, { target: { value: 'nginx' } });
});
expect(input.value).toBe('nginx');
expect(onValueChange).not.toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(120);
});
expect(onValueChange).toHaveBeenCalledTimes(1);
expect(onValueChange).toHaveBeenLastCalledWith('nginx');
});
it('does not clobber in-flight typing when the parent value echoes back the previous debounced emit', () => {
const onValueChange = vi.fn();
const { getByPlaceholderText, rerender } = renderInsideCommand({ value: '', onValueChange });
const input = getByPlaceholderText('Search stacks...') as HTMLInputElement;
act(() => {
fireEvent.input(input, { target: { value: 'web' } });
});
act(() => {
vi.advanceTimersByTime(120);
});
expect(onValueChange).toHaveBeenLastCalledWith('web');
// Parent now propagates 'web' back as the controlled value.
rerender(
<Command shouldFilter={false}>
<SidebarSearch value="web" onValueChange={onValueChange} />
</Command>,
);
// User keeps typing before the parent's echo settles.
act(() => {
fireEvent.input(input, { target: { value: 'web-api' } });
});
// The echo of 'web' must not overwrite 'web-api' on the input.
expect(input.value).toBe('web-api');
});
it('adopts an external reset of the parent value (e.g., clear)', () => {
const onValueChange = vi.fn();
const { getByPlaceholderText, rerender } = renderInsideCommand({ value: 'old-query', onValueChange });
const input = getByPlaceholderText('Search stacks...') as HTMLInputElement;
expect(input.value).toBe('old-query');
rerender(
<Command shouldFilter={false}>
<SidebarSearch value="" onValueChange={onValueChange} />
</Command>,
);
expect(input.value).toBe('');
});
});