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,
|
{ 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 ────────────────────────────────────────────────────────────
|
// ── Memberships ────────────────────────────────────────────────────────────
|
||||||
listMemberships: (orgId: string, requestConfig?: RequestConfig) =>
|
listMemberships: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ memberships: DeviceNetworkMembership[]; count: number }>(
|
request<{ memberships: DeviceNetworkMembership[]; count: number }>(
|
||||||
|
|||||||
+175
-41
@@ -15,6 +15,8 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Skull,
|
Skull,
|
||||||
Activity,
|
Activity,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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`;
|
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 }) {
|
function ApprovalStateBadge({ state }: { state: ApprovalState }) {
|
||||||
const config: Record<ApprovalState, { color: string; icon: React.ReactNode; label: string }> = {
|
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" },
|
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: "Approved" },
|
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: "Rejected" },
|
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: "Revoked" },
|
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: "Suspended" },
|
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 };
|
const { color, icon, label } = config[state] ?? { color: "bg-gray-500/10 text-gray-600 border-gray-200", icon: null, label: state };
|
||||||
return (
|
return (
|
||||||
@@ -123,10 +137,13 @@ export default function AccessPage() {
|
|||||||
const [selectedNetworkFilter, setSelectedNetworkFilter] = useState<string>("all");
|
const [selectedNetworkFilter, setSelectedNetworkFilter] = useState<string>("all");
|
||||||
|
|
||||||
const [approvalStateFilter, setApprovalStateFilter] = useState<ApprovalState | "all">("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 [approveId, setApproveId] = useState<string | null>(null);
|
||||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||||
const [revokeId, setRevokeId] = useState<string | null>(null);
|
const [revokeId, setRevokeId] = useState<string | null>(null);
|
||||||
|
const [unsuspendId, setUnsuspendId] = useState<string | null>(null);
|
||||||
const [isApproving, setIsApproving] = useState(false);
|
const [isApproving, setIsApproving] = useState(false);
|
||||||
const [rejectConfirmId, setRejectConfirmId] = useState<string | null>(null);
|
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 () => {
|
const handleKillSwitch = async () => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setKillError(null);
|
setKillError(null);
|
||||||
@@ -356,20 +391,6 @@ export default function AccessPage() {
|
|||||||
return true;
|
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) => {
|
const getNetworkName = (networkId: string) => {
|
||||||
return networks.find((n) => n.id === networkId)?.name ?? networkId;
|
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;
|
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 (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
@@ -621,21 +701,47 @@ export default function AccessPage() {
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<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">
|
||||||
<th className="text-left p-3 font-medium">Device</th>
|
<button onClick={() => handleSort("user")} className="inline-flex items-center gap-1 hover:text-foreground/80">
|
||||||
<th className="text-left p-3 font-medium">Network</th>
|
User {getSortIndicator("user")}
|
||||||
<th className="text-left p-3 font-medium">Status</th>
|
</button>
|
||||||
<th className="text-left p-3 font-medium">Requested</th>
|
</th>
|
||||||
<th className="text-left p-3 font-medium">Approved</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>
|
<th className="text-right p-3 font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y">
|
<tbody className="divide-y">
|
||||||
{filteredApprovals.map((approval) => (
|
{sortedApprovals.map((approval) => (
|
||||||
<tr key={approval.id} className="hover:bg-accent/30">
|
<tr key={approval.id} className="hover:bg-accent/30">
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="min-w-0">
|
<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 && (
|
{approval.justification && (
|
||||||
<p className="text-xs text-muted-foreground truncate max-w-[160px]" title={approval.justification}>
|
<p className="text-xs text-muted-foreground truncate max-w-[160px]" title={approval.justification}>
|
||||||
"{approval.justification}"
|
"{approval.justification}"
|
||||||
@@ -646,22 +752,30 @@ export default function AccessPage() {
|
|||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
{approval.device_name || approval.device_nickname ? (
|
{approval.device_name || approval.device_nickname ? (
|
||||||
<div>
|
<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 && (
|
{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>
|
<p className="text-xs text-muted-foreground font-mono truncate max-w-[160px]">{approval.device_id}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : approval.device_id ? (
|
) : 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>
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3">
|
<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>
|
||||||
<td className="p-3">
|
<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>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<span className="text-xs">{formatDate(approval.created_at)}</span>
|
<span className="text-xs">{formatDate(approval.created_at)}</span>
|
||||||
@@ -686,13 +800,13 @@ export default function AccessPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
disabled={
|
disabled={
|
||||||
(approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
|
(approval.status === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
|
||||||
((approval.status === "approved" || approval.status === "suspended") && (revokeId === 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 === "pending" && (approveId === approval.id || rejectId === approval.id)) ||
|
||||||
((approval.status === "approved" || approval.status === "suspended") && revokeId === approval.id)) ? (
|
((approval.status === "approved" || approval.status === "suspended") && (revokeId === approval.id || unsuspendId === approval.id))) ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
@@ -720,7 +834,27 @@ export default function AccessPage() {
|
|||||||
</DropdownMenuItem>
|
</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
|
<DropdownMenuItem
|
||||||
onClick={() => handleRevoke(approval.id)}
|
onClick={() => handleRevoke(approval.id)}
|
||||||
disabled={revokeId === approval.id || isApproving}
|
disabled={revokeId === approval.id || isApproving}
|
||||||
@@ -881,7 +1015,7 @@ export default function AccessPage() {
|
|||||||
Kill Switch
|
Kill Switch
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
@@ -889,7 +1023,7 @@ export default function AccessPage() {
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-sm text-destructive">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user