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:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user