fix(schedules): align Schedules surface with backend tier gate for Skipper admins (#1047)

The Schedules sidebar entry was hidden from Skipper admins even though the
backend permits them to create and run update, scan, and snapshot schedules.
The action picker also showed all 10 actions to every paid admin, so a Skipper
selecting Restart, Prune, or any auto_* lifecycle action would 403 on submit.

Changes:
- Extract SKIPPER_SCHEDULED_ACTIONS as the single source of truth in
  tierGates.ts; both requireScheduledTaskTier and the GET /scheduled-tasks
  list filter now reference it (replaces a duplicate local constant in
  scheduledTasks.ts).
- Move the Schedules nav entry from the Admiral block into the
  isPaid && isAdmin block in useViewNavigationState.ts, mirroring the
  existing Auto-Update pattern. Console and Audit stay Admiral-only.
- Filter the create-form action picker in ScheduledOperationsView.tsx by
  license variant. Skipper sees Auto-update Stack, Auto-update All Stacks,
  Fleet Snapshot, and Vulnerability Scan; Admiral sees the full set.
- openCreate now defaults formAction to the first visible option so Skipper
  starts with a valid choice instead of the Admiral-only Restart.

Tests:
- Add Skipper-variant POST coverage in scheduled-tasks-routes.test.ts:
  three allow cases (update / scan / snapshot) and a six-action rejection
  loop covering restart / prune / auto_backup / auto_stop / auto_down /
  auto_start.
- Flip the Skipper assertion in useViewNavigationState.test.tsx to expect
  scheduled-ops alongside auto-updates.
This commit is contained in:
Anso
2026-05-14 10:31:20 -04:00
committed by GitHub
parent 5461bc316b
commit 44e40afb62
6 changed files with 72 additions and 10 deletions
@@ -227,14 +227,14 @@ describe('useViewNavigationState', () => {
// ── navItems: skipper admin ────────────────────────────────────────────────
it('navItems for skipper paid admin contains auto-updates but not admiral items', () => {
it('navItems for skipper paid admin contains schedules and auto-updates but not admiral items', () => {
mockSkipperAdmin();
const { result } = renderHook(() => useViewNavigationState());
const values = result.current.navItems.map(i => i.value);
expect(values).toContain('auto-updates');
expect(values).toContain('scheduled-ops');
expect(values).not.toContain('host-console');
expect(values).not.toContain('audit-log');
expect(values).not.toContain('scheduled-ops');
});
// ── navItems: hub-only gating on remote node ───────────────────────────────
@@ -108,11 +108,11 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
];
if (isPaid && isAdmin) {
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
if (isPaid && license?.variant === 'admiral') {
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
}
return isRemote
? items.filter(i => !HUB_ONLY_VIEWS.has(i.value))