feat: add sorting, click-to-search, and restore access for suspended approvals

-
Sortable columns in All Approvals table (click headers to toggle asc/desc)
-
Click any User/Device/Network/Status cell to populate search and filter
-
Restore Access option for suspended memberships via assign endpoint
This commit is contained in:
2026-06-01 07:45:34 +00:00
parent 087b8f002f
commit 0bc18364d4
2 changed files with 181 additions and 41 deletions
+6
View File
@@ -1584,6 +1584,12 @@ export const api = {
{ method: "POST" }, true, requestConfig,
),
assignAccess: (orgId: string, data: { target_user_id: string; portal_network_id: string; justification?: string }, requestConfig?: RequestConfig) =>
request<{ approval: UserNetworkApproval }>(
`/organizations/${orgId}/approvals/assign`,
{ method: "POST", body: JSON.stringify(data) }, true, requestConfig,
),
// ── Memberships ────────────────────────────────────────────────────────────
listMemberships: (orgId: string, requestConfig?: RequestConfig) =>
request<{ memberships: DeviceNetworkMembership[]; count: number }>(
+175 -41
View File
@@ -15,6 +15,8 @@ import {
RefreshCw,
Skull,
Activity,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -91,13 +93,25 @@ function formatExpiry(d: string | null | undefined) {
return `${Math.floor(diff / 1440)}d ${Math.floor((diff % 1440) / 60)}h left`;
}
const STATUS_LABELS: Record<ApprovalState, string> = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
revoked: "Revoked",
suspended: "Suspended",
};
function getStatusLabel(state: ApprovalState) {
return STATUS_LABELS[state] ?? state;
}
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
const config: Record<ApprovalState, { color: string; icon: React.ReactNode; label: string }> = {
pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: "Pending" },
approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: <CheckCircle className="w-3 h-3 mr-1" />, label: "Approved" },
rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Rejected" },
revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: "Revoked" },
suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: <AlertTriangle className="w-3 h-3 mr-1" />, label: "Suspended" },
pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: <Clock className="w-3 h-3 mr-1" />, label: STATUS_LABELS.pending },
approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: <CheckCircle className="w-3 h-3 mr-1" />, label: STATUS_LABELS.approved },
rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: STATUS_LABELS.rejected },
revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: <XCircle className="w-3 h-3 mr-1" />, label: STATUS_LABELS.revoked },
suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: <AlertTriangle className="w-3 h-3 mr-1" />, label: STATUS_LABELS.suspended },
};
const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state };
return (
@@ -123,10 +137,13 @@ export default function AccessPage() {
const [selectedNetworkFilter, setSelectedNetworkFilter] = useState<string>("all");
const [approvalStateFilter, setApprovalStateFilter] = useState<ApprovalState | "all">("all");
const [sortColumn, setSortColumn] = useState("requested");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [approveId, setApproveId] = useState<string | null>(null);
const [rejectId, setRejectId] = useState<string | null>(null);
const [revokeId, setRevokeId] = useState<string | null>(null);
const [unsuspendId, setUnsuspendId] = useState<string | null>(null);
const [isApproving, setIsApproving] = useState(false);
const [rejectConfirmId, setRejectConfirmId] = useState<string | null>(null);
@@ -244,6 +261,24 @@ export default function AccessPage() {
}
};
const handleUnsuspend = async (approval: UserNetworkApproval) => {
if (!orgId) return;
setUnsuspendId(approval.id);
try {
await api.zerotier.assignAccess(orgId, {
target_user_id: approval.user_id,
portal_network_id: approval.portal_network_id,
justification: "Reinstating suspended membership",
});
toast({ title: "Access restored", description: "The user's access has been reinstated." });
fetchData();
} catch (err) {
toast({ variant: "destructive", title: "Failed to restore access", description: err instanceof ApiError ? err.message : "Something went wrong." });
} finally {
setUnsuspendId(null);
}
};
const handleKillSwitch = async () => {
if (!orgId) return;
setKillError(null);
@@ -356,20 +391,6 @@ export default function AccessPage() {
return true;
});
const filteredApprovals = approvals.filter((a) => {
if (approvalStateFilter !== "all" && a.state !== approvalStateFilter) return false;
if (selectedNetworkFilter !== "all" && a.portal_network_id !== selectedNetworkFilter) return false;
if (search) {
const q = search.toLowerCase();
const display = getUserDisplay(a.user_id).toLowerCase();
if (!display.includes(q)) return false;
}
return true;
});
const filteredSessions = sessions.filter((s) => s.is_active);
const activeSessions = filteredSessions;
const getNetworkName = (networkId: string) => {
return networks.find((n) => n.id === networkId)?.name ?? networkId;
};
@@ -379,6 +400,65 @@ export default function AccessPage() {
return member?.user?.email ?? member?.user?.full_name ?? userId;
};
const filteredApprovals = approvals.filter((a) => {
if (approvalStateFilter !== "all" && a.state !== approvalStateFilter) return false;
if (selectedNetworkFilter !== "all" && a.portal_network_id !== selectedNetworkFilter) return false;
if (search) {
const q = search.toLowerCase();
const userDisplay = getUserDisplay(a.user_id).toLowerCase();
const deviceName = (a.device_name || a.device_nickname || a.device_id || "").toLowerCase();
const networkName = getNetworkName(a.portal_network_id).toLowerCase();
const statusLabel = getStatusLabel(a.status).toLowerCase();
if (!userDisplay.includes(q) && !deviceName.includes(q) && !networkName.includes(q) && !statusLabel.includes(q)) return false;
}
return true;
});
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortColumn(column);
setSortDirection("asc");
}
};
const getSortIndicator = (column: string) => {
if (sortColumn !== column) return null;
return sortDirection === "asc" ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />;
};
const sortedApprovals = [...filteredApprovals].sort((a, b) => {
let cmp = 0;
switch (sortColumn) {
case "user":
cmp = getUserDisplay(a.user_id).localeCompare(getUserDisplay(b.user_id));
break;
case "device": {
const aDevice = a.device_name || a.device_nickname || a.device_id || "";
const bDevice = b.device_name || b.device_nickname || b.device_id || "";
cmp = aDevice.localeCompare(bDevice);
break;
}
case "network":
cmp = getNetworkName(a.portal_network_id).localeCompare(getNetworkName(b.portal_network_id));
break;
case "status":
cmp = a.status.localeCompare(b.status);
break;
case "requested":
cmp = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
break;
case "approved":
cmp = new Date(a.updated_at).getTime() - new Date(b.updated_at).getTime();
break;
}
return sortDirection === "asc" ? cmp : -cmp;
});
const filteredSessions = sessions.filter((s) => s.is_active);
const activeSessions = filteredSessions;
return (
<div className="page-container">
<div className="page-header">
@@ -621,21 +701,47 @@ export default function AccessPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left p-3 font-medium">User</th>
<th className="text-left p-3 font-medium">Device</th>
<th className="text-left p-3 font-medium">Network</th>
<th className="text-left p-3 font-medium">Status</th>
<th className="text-left p-3 font-medium">Requested</th>
<th className="text-left p-3 font-medium">Approved</th>
<th className="text-left p-3 font-medium">
<button onClick={() => handleSort("user")} className="inline-flex items-center gap-1 hover:text-foreground/80">
User {getSortIndicator("user")}
</button>
</th>
<th className="text-left p-3 font-medium">
<button onClick={() => handleSort("device")} className="inline-flex items-center gap-1 hover:text-foreground/80">
Device {getSortIndicator("device")}
</button>
</th>
<th className="text-left p-3 font-medium">
<button onClick={() => handleSort("network")} className="inline-flex items-center gap-1 hover:text-foreground/80">
Network {getSortIndicator("network")}
</button>
</th>
<th className="text-left p-3 font-medium">
<button onClick={() => handleSort("status")} className="inline-flex items-center gap-1 hover:text-foreground/80">
Status {getSortIndicator("status")}
</button>
</th>
<th className="text-left p-3 font-medium">
<button onClick={() => handleSort("requested")} className="inline-flex items-center gap-1 hover:text-foreground/80">
Requested {getSortIndicator("requested")}
</button>
</th>
<th className="text-left p-3 font-medium">
<button onClick={() => handleSort("approved")} className="inline-flex items-center gap-1 hover:text-foreground/80">
Approved {getSortIndicator("approved")}
</button>
</th>
<th className="text-right p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{filteredApprovals.map((approval) => (
{sortedApprovals.map((approval) => (
<tr key={approval.id} className="hover:bg-accent/30">
<td className="p-3">
<div className="min-w-0">
<p className="font-medium truncate max-w-[160px]">{getUserDisplay(approval.user_id)}</p>
<button type="button" onClick={() => setSearch(getUserDisplay(approval.user_id))} className="font-medium truncate max-w-[160px] text-left cursor-pointer hover:underline">
{getUserDisplay(approval.user_id)}
</button>
{approval.justification && (
<p className="text-xs text-muted-foreground truncate max-w-[160px]" title={approval.justification}>
"{approval.justification}"
@@ -646,22 +752,30 @@ export default function AccessPage() {
<td className="p-3">
{approval.device_name || approval.device_nickname ? (
<div>
<p className="text-sm font-medium truncate max-w-[160px]">{approval.device_name || approval.device_nickname}</p>
<button type="button" onClick={() => setSearch(approval.device_name || approval.device_nickname || "")} className="text-sm font-medium truncate max-w-[160px] text-left cursor-pointer hover:underline">
{approval.device_name || approval.device_nickname}
</button>
{approval.device_nickname && approval.device_name !== approval.device_nickname && (
<p className="text-xs text-muted-foreground font-mono truncate max-w-[160px]">{approval.device_id}</p>
)}
</div>
) : approval.device_id ? (
<span className="font-mono text-xs">{approval.device_id}</span>
<button type="button" onClick={() => setSearch(approval.device_id)} className="font-mono text-xs cursor-pointer hover:underline">
{approval.device_id}
</button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="p-3">
<span className="text-xs font-medium">{getNetworkName(approval.portal_network_id)}</span>
<button type="button" onClick={() => setSearch(getNetworkName(approval.portal_network_id))} className="text-xs font-medium cursor-pointer hover:underline">
{getNetworkName(approval.portal_network_id)}
</button>
</td>
<td className="p-3">
<ApprovalStateBadge state={approval.status} />
<button type="button" onClick={() => setSearch(getStatusLabel(approval.status))} className="cursor-pointer">
<ApprovalStateBadge state={approval.status} />
</button>
</td>
<td className="p-3">
<span className="text-xs">{formatDate(approval.created_at)}</span>
@@ -686,13 +800,13 @@ export default function AccessPage() {
size="sm"
variant="ghost"
className="h-8 w-8 p-0"
disabled={
(approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
((approval.status === "approved" || approval.status === "suspended") && (revokeId === approval.id))
}
>
{((approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
((approval.status === "approved" || approval.status === "suspended") && revokeId === approval.id)) ? (
disabled={
(approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
((approval.status === "approved" || approval.status === "suspended") && (revokeId === approval.id || unsuspendId === approval.id))
}
>
{((approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
((approval.status === "approved" || approval.status === "suspended") && (revokeId === approval.id || unsuspendId === approval.id))) ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<MoreHorizontal className="w-4 h-4" />
@@ -720,7 +834,27 @@ export default function AccessPage() {
</DropdownMenuItem>
</>
)}
{(approval.status === "approved" || approval.status === "suspended") && (
{approval.status === "suspended" && (
<DropdownMenuItem
onClick={() => handleUnsuspend(approval)}
disabled={unsuspendId === approval.id || isApproving}
className="text-amber-600 focus:text-amber-700"
>
{unsuspendId === approval.id ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RefreshCw className="w-4 h-4 mr-2" />}
Restore Access
</DropdownMenuItem>
)}
{approval.status === "approved" && (
<DropdownMenuItem
onClick={() => handleRevoke(approval.id)}
disabled={revokeId === approval.id || isApproving}
className="text-red-600 focus:text-red-700"
>
<XCircle className="w-4 h-4 mr-2" />
Revoke
</DropdownMenuItem>
)}
{approval.status === "suspended" && (
<DropdownMenuItem
onClick={() => handleRevoke(approval.id)}
disabled={revokeId === approval.id || isApproving}
@@ -881,7 +1015,7 @@ export default function AccessPage() {
Kill Switch
</DialogTitle>
<DialogDescription>
Instantly deactivate all active sessions for a user across all managed networks. This cannot be undone.
Instantly deactivate all active sessions for a user across all managed networks.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -889,7 +1023,7 @@ export default function AccessPage() {
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
<p className="text-sm text-destructive">
This will immediately de-authorize all ZeroTier memberships for the selected user across all networks.
This will immediately de-authorize all ZeroTier memberships for the selected user across all networks. They will not be able to re-connect until re-authorised
</p>
</div>
</div>