diff --git a/src/lib/api.ts b/src/lib/api.ts index ebb92cd..0cf4fc9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 }>( diff --git a/src/pages/org/AccessPage.tsx b/src/pages/org/AccessPage.tsx index a36ec2c..9447d8a 100644 --- a/src/pages/org/AccessPage.tsx +++ b/src/pages/org/AccessPage.tsx @@ -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 = { + 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 = { - pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: , label: "Pending" }, - approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: , label: "Approved" }, - rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Rejected" }, - revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: "Revoked" }, - suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: , label: "Suspended" }, + pending: { color: "bg-yellow-500/10 text-yellow-600 border-yellow-200", icon: , label: STATUS_LABELS.pending }, + approved: { color: "bg-green-500/10 text-green-600 border-green-200", icon: , label: STATUS_LABELS.approved }, + rejected: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: STATUS_LABELS.rejected }, + revoked: { color: "bg-red-500/10 text-red-600 border-red-200", icon: , label: STATUS_LABELS.revoked }, + suspended: { color: "bg-orange-500/10 text-orange-600 border-orange-200", icon: , 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("all"); const [approvalStateFilter, setApprovalStateFilter] = useState("all"); + const [sortColumn, setSortColumn] = useState("requested"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); const [approveId, setApproveId] = useState(null); const [rejectId, setRejectId] = useState(null); const [revokeId, setRevokeId] = useState(null); + const [unsuspendId, setUnsuspendId] = useState(null); const [isApproving, setIsApproving] = useState(false); const [rejectConfirmId, setRejectConfirmId] = useState(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" ? : ; + }; + + 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 (
@@ -621,21 +701,47 @@ export default function AccessPage() { - - - - - - + + + + + + - {filteredApprovals.map((approval) => ( + {sortedApprovals.map((approval) => (
UserDeviceNetworkStatusRequestedApproved + + + + + + + + + + + + Actions
-

{getUserDisplay(approval.user_id)}

+ {approval.justification && (

"{approval.justification}" @@ -646,22 +752,30 @@ export default function AccessPage() {

{approval.device_name || approval.device_nickname ? (
-

{approval.device_name || approval.device_nickname}

+ {approval.device_nickname && approval.device_name !== approval.device_nickname && (

{approval.device_id}

)}
) : approval.device_id ? ( - {approval.device_id} + ) : ( )}
- {getNetworkName(approval.portal_network_id)} + - + {formatDate(approval.created_at)} @@ -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))) ? ( ) : ( @@ -720,7 +834,27 @@ export default function AccessPage() { )} - {(approval.status === "approved" || approval.status === "suspended") && ( + {approval.status === "suspended" && ( + handleUnsuspend(approval)} + disabled={unsuspendId === approval.id || isApproving} + className="text-amber-600 focus:text-amber-700" + > + {unsuspendId === approval.id ? : } + Restore Access + + )} + {approval.status === "approved" && ( + handleRevoke(approval.id)} + disabled={revokeId === approval.id || isApproving} + className="text-red-600 focus:text-red-700" + > + + Revoke + + )} + {approval.status === "suspended" && ( handleRevoke(approval.id)} disabled={revokeId === approval.id || isApproving} @@ -881,7 +1015,7 @@ export default function AccessPage() { Kill Switch - 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.
@@ -889,7 +1023,7 @@ export default function AccessPage() {

- 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