mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-22 08:06:42 +00:00
fix(nodes): gate node-management actions by role and release pilot tunnels on delete (#1280)
* fix(nodes): gate node-management actions by role and release pilot tunnels on delete The node-management write actions in the Nodes panel (add, edit, delete, generate node token, reset fleet-sync anchor) rendered for every signed-in role, but the API enforces the manage-nodes permission on them, so lower-privilege roles saw buttons that returned 403. The panel now renders each action against the same permission its route enforces; the read-only node table stays visible to every role. Deleting a node now also tears down its live pilot-agent tunnel (and any mesh bridge) immediately, releasing the loopback server, heartbeat timer, and open streams instead of leaving them until the agent next disconnects, matching the cleanup the re-enrollment path already performed. Adds backend route tests for the permission boundaries and tunnel teardown, and a Nodes panel render test covering the viewer, admin, and node-admin views. * fix(nodes): close proxy mesh bridges via the dialer on node delete to skip a redial Deleting a node closed any active mesh bridge through PilotTunnelManager, but a proxy-mode bridge is owned by the mesh dialer, whose close listener then treated the close as unexpected and scheduled a reactive redial against the node being removed. The delete handler now closes a proxy bridge through the dialer's intentional-close path first (which suppresses the redial), then closes a pilot-agent tunnel as before. Adds a backend test that primes a live proxy bridge and asserts deletion closes it without scheduling a redial.
This commit is contained in:
@@ -35,8 +35,15 @@ export interface SenchoNavigateDetail {
|
||||
|
||||
export function NodeManager() {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canEditLabels = isPaid && isAdmin;
|
||||
// Mirror the backend node:manage guard. This top-level flag checks the global
|
||||
// role only (admin or global node-admin); the per-row Edit/Delete buttons below
|
||||
// additionally honor scoped per-node grants via can('node:manage', 'node', id).
|
||||
// Admins resolve immediately via isAdmin; node-admins once /permissions/me lands.
|
||||
// Generate-token and reset-anchor below stay admin-only to match their stricter
|
||||
// backend guards (requireAdmin, and requireAdmin + requirePaid).
|
||||
const canManageNodes = isAdmin || can('node:manage');
|
||||
const { nodes, refreshNodeMeta } = useNodes();
|
||||
useMastheadStats([
|
||||
{ label: 'NODES', value: `${nodes.length}` },
|
||||
@@ -189,21 +196,29 @@ export function NodeManager() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end">
|
||||
<SettingsPrimaryButton
|
||||
size="sm"
|
||||
className="gap-1 shrink-0"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add node
|
||||
</SettingsPrimaryButton>
|
||||
</div>
|
||||
{/* Actions (node management is admin / node-admin only, mirroring the
|
||||
node:manage backend guard). The read-only table below stays visible to
|
||||
every role with node:read. */}
|
||||
{canManageNodes && (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<SettingsPrimaryButton
|
||||
size="sm"
|
||||
className="gap-1 shrink-0"
|
||||
onClick={openCreate}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add node
|
||||
</SettingsPrimaryButton>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Generate Node Token - for use on THIS instance as a remote target */}
|
||||
{/* Generate a node token so THIS instance can serve as a remote target.
|
||||
Admin-only, matching the requireAdmin guard on /auth/generate-node-token. */}
|
||||
{isAdmin && (
|
||||
<div className="rounded-md border p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -235,6 +250,7 @@ export function NodeManager() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync issues: surfaces FleetSync sticky errors (currently CONTROL_IDENTITY_MISMATCH). */}
|
||||
{anchorMismatches.length > 0 && (
|
||||
@@ -257,15 +273,17 @@ export function NodeManager() {
|
||||
{' '}Reset the anchor on the peer to resume sync, or remove the node from this fleet.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleResetAnchor(nodeId)}
|
||||
disabled={resettingAnchor === nodeId}
|
||||
>
|
||||
{resettingAnchor === nodeId ? 'Resetting...' : 'Reset anchor on peer'}
|
||||
</Button>
|
||||
{node && !node.is_default && (
|
||||
{isAdmin && isPaid && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleResetAnchor(nodeId)}
|
||||
disabled={resettingAnchor === nodeId}
|
||||
>
|
||||
{resettingAnchor === nodeId ? 'Resetting...' : 'Reset anchor on peer'}
|
||||
</Button>
|
||||
)}
|
||||
{node && !node.is_default && (isAdmin || can('node:manage', 'node', String(nodeId))) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -299,7 +317,9 @@ export function NodeManager() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{nodes.map((node) => (
|
||||
{nodes.map((node) => {
|
||||
const canManageThis = isAdmin || can('node:manage', 'node', String(node.id));
|
||||
return (
|
||||
<TableRow key={node.id}>
|
||||
<TableCell>
|
||||
{node.is_default && (
|
||||
@@ -451,24 +471,26 @@ export function NodeManager() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEdit(node)}
|
||||
aria-label="Edit node"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit Node</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{canManageThis && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => openEdit(node)}
|
||||
aria-label="Edit node"
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit Node</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{!node.is_default && (
|
||||
{!node.is_default && canManageThis && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -489,7 +511,8 @@ export function NodeManager() {
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user