fix(auto-heal): gate panel write controls on admin role (#1245)

The Auto-Heal panel rendered its add-policy form, the per-policy enable
toggle, and the per-policy delete button without any admin guard, but
POST/PATCH/DELETE on /api/auto-heal/policies enforce requireAdmin +
requirePaid. Paid non-admin users could see the controls and would hit a
403 on click. Same drift class as the Schedule task gate already moved
in the prior sidebar parity pass.

Gate the add-new-policy section, the enable toggle, and the delete button
on the admin role. Reading existing policies and viewing history stay
available to every paid user, matching the backend GET routes which are
requirePaid only.

Docs updated to call out the admin requirement on Auto-Heal write
actions and to add a troubleshooting accordion for non-admin operators
who see the panel without the configuration controls.
This commit is contained in:
Anso
2026-05-28 15:27:40 -04:00
committed by GitHub
parent 0a8e6a79ae
commit b034de58f3
2 changed files with 30 additions and 18 deletions
+4 -1
View File
@@ -85,7 +85,7 @@ Right-click any stack (or open the kebab that appears on hover) for its context
- **Destructive**: **Delete**.
<Note>
**Auto-Heal** requires a **Skipper** or **Admiral** license. **Schedule task** requires a **Skipper** or **Admiral** license and an **admin** role.
**Auto-Heal** requires a **Skipper** or **Admiral** license; configuring policies (add, toggle, delete) also requires an **admin** role. **Schedule task** requires a **Skipper** or **Admiral** license and an **admin** role.
</Note>
<Frame>
@@ -146,4 +146,7 @@ The footer surfaces the most recent stack lifecycle event on the node. Each tick
<Accordion title="Schedule task is missing from the right-click menu">
Scheduling requires a **Skipper** or **Admiral** license and an **admin** role. Ask an admin on this node to schedule the task for you, or sign in with an admin account.
</Accordion>
<Accordion title="Auto-Heal panel opens but Add Policy and the toggles are missing">
Reading existing Auto-Heal policies only needs a **Skipper** or **Admiral** license, so the panel still opens for non-admin operators on paid tiers. Adding, enabling, disabling, or deleting policies also requires the **admin** role. Sign in with an admin account, or ask an admin on this node to make the change.
</Accordion>
</AccordionGroup>
+26 -17
View File
@@ -482,6 +482,7 @@ function AlertsTab({ stackName }: { stackName: string }) {
}
function AutoHealTab({ stackName, open }: { stackName: string; open: boolean }) {
const { isAdmin } = useAuth();
const [policies, setPolicies] = useState<AutoHealPolicy[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -617,12 +618,14 @@ function AutoHealTab({ stackName, open }: { stackName: string; open: boolean })
onToggle={handleToggle}
deleting={deleting}
saving={saving}
isAdmin={isAdmin}
/>
))}
</div>
)}
</SheetSection>
{isAdmin && (
<SheetSection title="Add new policy">
<div className="space-y-3">
<div className="space-y-2">
@@ -700,6 +703,7 @@ function AutoHealTab({ stackName, open }: { stackName: string; open: boolean })
</Button>
</div>
</SheetSection>
)}
</>
);
}
@@ -710,9 +714,10 @@ interface PolicyRowProps {
onToggle: (id: number, enabled: boolean) => void;
deleting: boolean;
saving: boolean;
isAdmin: boolean;
}
function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowProps) {
function PolicyRow({ policy, onDelete, onToggle, deleting, saving, isAdmin }: PolicyRowProps) {
const [historyOpen, setHistoryOpen] = useState(false);
const [history, setHistory] = useState<AutoHealHistoryEntry[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
@@ -760,12 +765,14 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowPr
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<TogglePill
checked={policy.enabled === 1}
onChange={(checked) => policy.id != null && onToggle(policy.id, checked)}
disabled={saving}
aria-label={`Toggle policy for ${policy.service_name ?? 'all services'}`}
/>
{isAdmin && (
<TogglePill
checked={policy.enabled === 1}
onChange={(checked) => policy.id != null && onToggle(policy.id, checked)}
disabled={saving}
aria-label={`Toggle policy for ${policy.service_name ?? 'all services'}`}
/>
)}
<Button
variant="ghost"
size="icon"
@@ -782,16 +789,18 @@ function PolicyRow({ policy, onDelete, onToggle, deleting, saving }: PolicyRowPr
<ChevronDown className="h-4 w-4" strokeWidth={1.5} />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => policy.id != null && onDelete(policy.id)}
disabled={deleting}
aria-label="Delete policy"
>
<Trash2 className="h-4 w-4" strokeWidth={1.5} />
</Button>
{isAdmin && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground"
onClick={() => policy.id != null && onDelete(policy.id)}
disabled={deleting}
aria-label="Delete policy"
>
<Trash2 className="h-4 w-4" strokeWidth={1.5} />
</Button>
)}
</div>
</div>