From fe0b114ebf461806128278f088c12009a7fccae3 Mon Sep 17 00:00:00 2001 From: cory Date: Fri, 29 May 2026 06:28:26 +0000 Subject: [PATCH] Removed requeets form netweorks tab Actiev sessions now refreshes every 5 seconds Better session mgmt --- src/lib/api.ts | 57 ++++++++- src/pages/org/AccessPage.tsx | 117 ++++++++++++++++--- src/pages/org/DevicesPage.tsx | 93 ++++++++++++--- src/pages/org/NetworkManagementPage.tsx | 148 +----------------------- 4 files changed, 232 insertions(+), 183 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 8736692..0d76b71 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1663,15 +1663,20 @@ export const api = { ), // ── Sessions ────────────────────────────────────────────────────────────── - listSessions: (orgId: string, requestConfig?: RequestConfig) => - request<{ sessions: ActivationSession[]; count: number }>( + listUserSessions: (orgId: string, requestConfig?: RequestConfig) => + request<{ sessions: UserSession[]; count: number }>( `/organizations/${orgId}/sessions`, {}, true, requestConfig, ), - endSession: (orgId: string, sessionId: string, requestConfig?: RequestConfig) => - request<{ message: string }>( - `/organizations/${orgId}/sessions/${sessionId}`, - { method: "DELETE" }, true, requestConfig, + adminListSessions: (orgId: string, requestConfig?: RequestConfig) => + request<{ sessions: AdminSession[]; count: number }>( + `/organizations/${orgId}/admin/sessions`, {}, true, requestConfig, + ), + + adminEndSession: (orgId: string, sessionId: string, requestConfig?: RequestConfig) => + request<{ session: ActivationSession; message: string }>( + `/organizations/${orgId}/admin/sessions/${sessionId}/end`, + { method: "POST", body: "{}" }, true, requestConfig, ), // ── Kill Switch ─────────────────────────────────────────────────────────── @@ -2193,6 +2198,46 @@ export interface ActivationSession { is_active: boolean; } +export interface UserSessionDevice { + id: string; + node_id: string; + name: string; +} + +export interface UserSessionNetwork { + id: string; + name: string; +} + +export interface UserSession { + id: string; + authenticated_at: string; + expires_at: string; + duration_seconds: number; + remaining_seconds: number; + is_active: boolean; + is_expired: boolean; + ended_at: string | null; + end_reason: string | null; + device: UserSessionDevice; + network: UserSessionNetwork; +} + +export interface AdminSession { + id: string; + user: { id: string; full_name: string; email: string }; + authenticated_at: string; + expires_at: string; + duration_seconds: number; + remaining_seconds: number; + is_active: boolean; + is_expired: boolean; + ended_at: string | null; + end_reason: string | null; + device: UserSessionDevice; + network: UserSessionNetwork; +} + export interface KillSwitchEvent { id: string; organization_id: string; diff --git a/src/pages/org/AccessPage.tsx b/src/pages/org/AccessPage.tsx index acb294b..1561da6 100644 --- a/src/pages/org/AccessPage.tsx +++ b/src/pages/org/AccessPage.tsx @@ -57,7 +57,7 @@ import { api, ApiError, UserNetworkApproval, - ActivationSession, + AdminSession, KillSwitchEvent, PortalNetwork, OrganizationMember, @@ -114,7 +114,7 @@ export default function AccessPage() { const [approvals, setApprovals] = useState([]); const [pendingApprovals, setPendingApprovals] = useState([]); - const [sessions, setSessions] = useState([]); + const [sessions, setSessions] = useState([]); const [killSwitchEvents, setKillSwitchEvents] = useState([]); const [networks, setNetworks] = useState([]); const [orgMembers, setOrgMembers] = useState([]); @@ -143,7 +143,8 @@ export default function AccessPage() { const [killError, setKillError] = useState(null); const [endSessionId, setEndSessionId] = useState(null); - const [isEndingSession, setIsEndingSession] = useState(false); + const [showEndSessionConfirm, setShowEndSessionConfirm] = useState(false); + const [endSessionTarget, setEndSessionTarget] = useState(null); const [selectedApproval, setSelectedApproval] = useState(null); const [allMemberships, setAllMemberships] = useState([]); @@ -164,7 +165,7 @@ export default function AccessPage() { const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([ api.zerotier.listPendingApprovals(orgId), api.zerotier.adminListAllApprovals(orgId), - api.zerotier.listSessions(orgId), + api.zerotier.adminListSessions(orgId), api.zerotier.listNetworks(orgId), api.organizations.getMembers(orgId), api.zerotier.adminListAllMemberships(orgId), @@ -188,6 +189,21 @@ export default function AccessPage() { fetchData(); }, [fetchData]); + const refreshSessions = useCallback(async () => { + if (!orgId) return; + try { + const sessionsRes = await api.zerotier.adminListSessions(orgId); + setSessions(sessionsRes.sessions || []); + } catch { + // silent + } + }, [orgId]); + + useEffect(() => { + const interval = setInterval(() => refreshSessions(), 5000); + return () => clearInterval(interval); + }, [refreshSessions]); + const handleApprove = async (approvalId: string) => { if (!orgId) return; setApproveId(approvalId); @@ -278,18 +294,42 @@ export default function AccessPage() { } }; - const handleEndSession = async (sessionId: string) => { - if (!orgId) return; + const handleEndSession = (session: AdminSession) => { + setEndSessionTarget(session); + setShowEndSessionConfirm(true); + }; + + const handleEndSessionConfirm = async () => { + if (!orgId || !endSessionTarget) return; + const sessionId = endSessionTarget.id; setEndSessionId(sessionId); - setIsEndingSession(true); + setShowEndSessionConfirm(false); try { - await api.zerotier.endSession(orgId, sessionId); - toast({ title: "Session ended" }); - fetchData(); + const res = await api.zerotier.adminEndSession(orgId, sessionId); + setSessions((prev) => + prev.map((s) => + s.id === sessionId + ? { ...s, ended_at: res.session.ended_at, is_expired: true, is_active: false } + : s + ) + ); + toast({ title: "Session ended", description: res.message }); } catch (err) { - toast({ variant: "destructive", title: "Failed to end session", description: err instanceof ApiError ? err.message : "Something went wrong." }); + if (err instanceof ApiError) { + if (err.message?.includes("NOT_FOUND")) { + toast({ variant: "destructive", title: "Session not found", description: `Session ${sessionId} not found.` }); + } else if (err.message?.includes("already ended")) { + toast({ variant: "destructive", title: "Session already ended", description: "This session has already been ended." }); + } else { + toast({ variant: "destructive", title: "Failed to end session", description: err.message }); + } + } else { + toast({ variant: "destructive", title: "Failed to end session", description: "Something went wrong." }); + } + fetchData(); } finally { setEndSessionId(null); + setEndSessionTarget(null); } }; @@ -510,12 +550,34 @@ export default function AccessPage() {
-

{session.device_network_membership_id}

-
+

{session.user?.full_name || session.user?.email || "Unknown user"}

+ {session.user?.email && ( +

{session.user.email}

+ )} +
+ {session.device && ( + + {session.device.name || session.device.node_id} + + )} + {session.network && ( + + {session.network.name} + + )} +
+
Activated: {formatDate(session.authenticated_at)} - {formatExpiry(session.expires_at)} + {session.remaining_seconds > 0 + ? (() => { + const min = Math.floor(session.remaining_seconds / 60); + return min >= 60 + ? `${Math.floor(min / 60)}h ${min % 60}m remaining` + : `${min}m remaining`; + })() + : "Expired"}
@@ -523,7 +585,7 @@ export default function AccessPage() { size="sm" variant="outline" className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0" - onClick={() => handleEndSession(session.id)} + onClick={() => handleEndSession(session)} disabled={endSessionId === session.id} > {endSessionId === session.id ? : } @@ -835,6 +897,31 @@ export default function AccessPage() { + + {/* End Session Confirmation Dialog */} + { if (!open) { setShowEndSessionConfirm(false); setEndSessionTarget(null); } }}> + + + End Session + + End session for {endSessionTarget?.user?.full_name || endSessionTarget?.user?.email || "this user"} on {endSessionTarget?.network?.name || "this network"}? They will need to re-authenticate. + + +
+
+ + The user's approval will NOT be revoked — they can re-authenticate without admin re-approval. The device will be deauthorized from the ZeroTier network and lose connectivity until they re-authenticate. +
+
+ + + + +
+
); } diff --git a/src/pages/org/DevicesPage.tsx b/src/pages/org/DevicesPage.tsx index 149d36c..64065c4 100644 --- a/src/pages/org/DevicesPage.tsx +++ b/src/pages/org/DevicesPage.tsx @@ -55,7 +55,7 @@ import { ApiError, Device, DeviceNetworkMembership, - ActivationSession, + UserSession, MembershipState, PortalNetwork, UserNetworkApproval, @@ -138,7 +138,7 @@ function ActiveBadge({ active }: { active: boolean }) { ); } -function SessionProgress({ session }: { session: ActivationSession }) { +function SessionProgress({ session }: { session: { authenticated_at: string; expires_at: string } }) { const now = Date.now(); const expires = new Date(session.expires_at).getTime(); const created = new Date(session.authenticated_at).getTime(); @@ -195,7 +195,7 @@ export default function DevicesPage() { const [devices, setDevices] = useState([]); const [memberships, setMemberships] = useState([]); - const [sessions, setSessions] = useState([]); + const [sessions, setSessions] = useState([]); const [networks, setNetworks] = useState([]); const [myApprovals, setMyApprovals] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -246,7 +246,7 @@ export default function DevicesPage() { const [devicesRes, membershipsRes, sessionsRes, networksRes, approvalsRes] = await Promise.allSettled([ api.zerotier.listDevices(orgId), api.zerotier.listMemberships(orgId), - api.zerotier.listSessions(orgId), + api.zerotier.listUserSessions(orgId), api.zerotier.listNetworks(orgId), api.zerotier.listMyApprovals(orgId), ]); @@ -268,6 +268,21 @@ export default function DevicesPage() { fetchData(); }, [fetchData]); + const refreshSessions = useCallback(async () => { + if (!orgId) return; + try { + const sessionsRes = await api.zerotier.listUserSessions(orgId); + setSessions(sessionsRes.sessions || []); + } catch { + // silent + } + }, [orgId]); + + useEffect(() => { + const interval = setInterval(() => refreshSessions(), 5000); + return () => clearInterval(interval); + }, [refreshSessions]); + const handleRegister = async () => { if (!orgId) return; setRegError(null); @@ -434,14 +449,19 @@ export default function DevicesPage() { ); }); - const getActiveSession = (membershipId: string): ActivationSession | null => { - return sessions.find((s) => s.device_network_membership_id === membershipId && s.is_active) ?? null; + const getActiveSession = (deviceId: string, networkId: string): UserSession | null => { + return sessions.find((s) => s.device?.id === deviceId && s.network?.id === networkId && s.is_active) ?? null; }; const getMembershipForDeviceAndNetwork = (deviceId: string, networkId: string): DeviceNetworkMembership | null => { return memberships.find((m) => m.device_id === deviceId && m.portal_network_id === networkId) ?? null; }; + const getMembershipBySession = (session: UserSession): DeviceNetworkMembership | undefined => { + if (!session.device || !session.network) return undefined; + return memberships.find((m) => m.device_id === session.device.id && m.portal_network_id === session.network.id); + }; + const getApprovalForNetwork = (networkId: string): UserNetworkApproval | null => { return myApprovals.find((a) => a.portal_network_id === networkId) ?? null; }; @@ -629,7 +649,7 @@ export default function DevicesPage() { ) : (
{deviceMemberships.map((m) => { - const session = getActiveSession(m.id); + const session = getActiveSession(m.device_id, m.portal_network_id); const network = networks.find((n) => n.id === m.portal_network_id); return (
@@ -709,17 +729,56 @@ export default function DevicesPage() {
- {sessions.filter((s) => s.is_active).map((session) => ( -
- {session.device_network_membership_id} -
- Expires: {formatExpiry(session.expires_at)} - + {sessions.filter((s) => s.is_active).map((session) => { + const membership = getMembershipBySession(session); + return ( +
+
+ +
+
+
+ {session.device && ( + + {session.device.name || session.device.node_id} + + )} + {session.network && ( + + {session.network.name} + + )} +
+
+ Activated: {formatDate(session.authenticated_at)} + + + {session.remaining_seconds > 0 + ? (() => { + const min = Math.floor(session.remaining_seconds / 60); + return min >= 60 + ? `${Math.floor(min / 60)}h ${min % 60}m remaining` + : `${min}m remaining`; + })() + : "Expired"} + +
+
+ {membership && ( + + )}
-
- ))} + ); + })}
diff --git a/src/pages/org/NetworkManagementPage.tsx b/src/pages/org/NetworkManagementPage.tsx index 1331e84..eca3d3e 100644 --- a/src/pages/org/NetworkManagementPage.tsx +++ b/src/pages/org/NetworkManagementPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { Network, ArrowLeft, Loader2, AlertTriangle, Users, Zap, ZapOff, Ban, Shield, Monitor, CheckCircle, Clock, ChevronDown, ChevronRight, Plus, Search, Trash2 } from "lucide-react"; +import { Network, ArrowLeft, Loader2, AlertTriangle, Users, Zap, ZapOff, Ban, Shield, Monitor, CheckCircle, ChevronDown, ChevronRight, Plus, Search, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { api, PortalNetwork, ApiError, NetworkEnvironment, NetworkRequestMode, DeviceNetworkMembership, UserNetworkApproval, OrgMember, Device, ActivationSession, ApprovalState } from "@/lib/api"; +import { api, PortalNetwork, ApiError, NetworkEnvironment, NetworkRequestMode, DeviceNetworkMembership, OrgMember, Device, ActivationSession } from "@/lib/api"; import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization"; import { useToast } from "@/hooks/use-toast"; import { @@ -109,21 +109,6 @@ function SessionProgress({ session }: { session: ActivationSession }) { ); } -function ApprovalStateBadge({ state }: { state: string }) { - const colors: Record = { - pending: "bg-yellow-100 text-yellow-700 border-yellow-200", - approved: "bg-green-100 text-green-700 border-green-200", - rejected: "bg-red-100 text-red-700 border-red-200", - revoked: "bg-red-100 text-red-700 border-red-200", - suspended: "bg-orange-100 text-orange-700 border-orange-200", - }; - return ( - - {state.charAt(0).toUpperCase() + state.slice(1)} - - ); -} - export default function NetworkManagementPage() { const { networkId } = useParams<{ networkId: string }>(); const { orgId } = useCurrentOrganizationId(); @@ -148,12 +133,6 @@ export default function NetworkManagementPage() { const [activateLifetime, setActivateLifetime] = useState("480"); const [activatingAll, setActivatingAll] = useState(false); - const [requests, setRequests] = useState([]); - const [isRequestsLoading, setIsRequestsLoading] = useState(false); - const [requestsError, setRequestsError] = useState(null); - const [approvingRequest, setApprovingRequest] = useState(null); - const [rejectingRequest, setRejectingRequest] = useState(null); - // Add New Membership dialog state const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [addStep, setAddStep] = useState<1 | 2 | 3>(1); @@ -210,23 +189,6 @@ export default function NetworkManagementPage() { fetchMembers(); }, [orgId, networkId]); - useEffect(() => { - async function fetchRequests() { - if (!orgId || !networkId) return; - setIsRequestsLoading(true); - setRequestsError(null); - try { - const result = await api.zerotier.getNetworkPendingRequests(orgId, networkId); - setRequests(result.requests || []); - } catch (err) { - setRequestsError(err instanceof ApiError ? err.message : "Failed to load requests."); - } finally { - setIsRequestsLoading(false); - } - } - fetchRequests(); - }, [orgId, networkId]); - // Fetch org members when dialog opens useEffect(() => { if (!isAddDialogOpen || !orgId) return; @@ -385,37 +347,6 @@ export default function NetworkManagementPage() { } }; - const handleApprove = async (approvalId: string) => { - if (!orgId) return; - setApprovingRequest(approvalId); - try { - await api.zerotier.approveRequest(orgId, approvalId); - toast({ title: "Request approved" }); - // Refresh requests - const result = await api.zerotier.getNetworkPendingRequests(orgId, networkId!); - setRequests(result.requests || []); - } catch (err) { - toast({ variant: "destructive", title: "Failed to approve", description: err instanceof ApiError ? err.message : "Something went wrong." }); - } finally { - setApprovingRequest(null); - } - }; - - const handleReject = async (approvalId: string) => { - if (!orgId) return; - setRejectingRequest(approvalId); - try { - await api.zerotier.rejectRequest(orgId, approvalId); - toast({ title: "Request rejected" }); - const result = await api.zerotier.getNetworkPendingRequests(orgId, networkId!); - setRequests(result.requests || []); - } catch (err) { - toast({ variant: "destructive", title: "Failed to reject", description: err instanceof ApiError ? err.message : "Something went wrong." }); - } finally { - setRejectingRequest(null); - } - }; - // ── SKELETON STATE ────────────────────────────────────────────────────────── if (isLoading) { return ( @@ -486,7 +417,6 @@ export default function NetworkManagementPage() { Overview Members - Requests @@ -758,79 +688,7 @@ export default function NetworkManagementPage() { )} - - - - - - - Access Requests - - {requests.length} pending - - - - {isRequestsLoading ? ( -
- - Loading requests… -
- ) : requestsError ? ( -
{requestsError}
- ) : requests.length === 0 ? ( -
No pending requests for this network.
- ) : ( -
- {requests.map((r) => ( -
-
- -
-
-
-

{r.user_name || r.user_id}

- - {r.grant_type} -
- {r.user_email && ( -

{r.user_email}

- )} - {r.justification && ( -

"{r.justification}"

- )} -

- Requested: {formatDate(r.created_at)} -

-
- {r.state === "pending" && ( -
- - -
- )} -
- ))} -
- )} -
-
-
+ {/* Add New Membership Dialog */}