Removed requeets form netweorks tab

Actiev sessions now refreshes every 5 seconds
Better session mgmt
This commit is contained in:
2026-05-29 06:28:26 +00:00
parent a13e298d8a
commit fe0b114ebf
4 changed files with 232 additions and 183 deletions
+51 -6
View File
@@ -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;
+102 -15
View File
@@ -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<UserNetworkApproval[]>([]);
const [pendingApprovals, setPendingApprovals] = useState<UserNetworkApproval[]>([]);
const [sessions, setSessions] = useState<ActivationSession[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [killSwitchEvents, setKillSwitchEvents] = useState<KillSwitchEvent[]>([]);
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
@@ -143,7 +143,8 @@ export default function AccessPage() {
const [killError, setKillError] = useState<string | null>(null);
const [endSessionId, setEndSessionId] = useState<string | null>(null);
const [isEndingSession, setIsEndingSession] = useState(false);
const [showEndSessionConfirm, setShowEndSessionConfirm] = useState(false);
const [endSessionTarget, setEndSessionTarget] = useState<AdminSession | null>(null);
const [selectedApproval, setSelectedApproval] = useState<UserNetworkApproval | null>(null);
const [allMemberships, setAllMemberships] = useState<EnrichedMembership[]>([]);
@@ -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() {
<Zap className="w-4 h-4 text-green-500" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium font-mono truncate">{session.device_network_membership_id}</p>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<p className="font-medium truncate">{session.user?.full_name || session.user?.email || "Unknown user"}</p>
{session.user?.email && (
<p className="text-xs text-muted-foreground">{session.user.email}</p>
)}
<div className="flex items-center gap-2 mt-1 flex-wrap">
{session.device && (
<Badge variant="outline" className="text-xs font-mono">
{session.device.name || session.device.node_id}
</Badge>
)}
{session.network && (
<Badge variant="secondary" className="text-xs">
{session.network.name}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span>Activated: {formatDate(session.authenticated_at)}</span>
<span className="text-green-600 font-medium flex items-center gap-1">
<Clock className="w-3 h-3" />
{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"}
</span>
</div>
</div>
@@ -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 ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
@@ -835,6 +897,31 @@ export default function AccessPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* End Session Confirmation Dialog */}
<Dialog open={showEndSessionConfirm} onOpenChange={(open) => { if (!open) { setShowEndSessionConfirm(false); setEndSessionTarget(null); } }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>End Session</DialogTitle>
<DialogDescription>
End session for <strong>{endSessionTarget?.user?.full_name || endSessionTarget?.user?.email || "this user"}</strong> on <strong>{endSessionTarget?.network?.name || "this network"}</strong>? They will need to re-authenticate.
</DialogDescription>
</DialogHeader>
<div className="p-3 border border-orange-300 rounded-lg bg-orange-50 text-sm text-orange-800">
<div className="flex items-start gap-2">
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>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.</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowEndSessionConfirm(false); setEndSessionTarget(null); }}>Cancel</Button>
<Button variant="destructive" onClick={handleEndSessionConfirm}>
<ZapOff className="w-4 h-4 mr-2" />
End Session
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+76 -17
View File
@@ -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<Device[]>([]);
const [memberships, setMemberships] = useState<DeviceNetworkMembership[]>([]);
const [sessions, setSessions] = useState<ActivationSession[]>([]);
const [sessions, setSessions] = useState<UserSession[]>([]);
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
const [myApprovals, setMyApprovals] = useState<UserNetworkApproval[]>([]);
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() {
) : (
<div className="space-y-3">
{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 (
<div key={m.id} className="p-3 border rounded-lg space-y-2">
@@ -709,17 +729,56 @@ export default function DevicesPage() {
</Button>
</div>
<div className="mt-2 space-y-1">
{sessions.filter((s) => s.is_active).map((session) => (
<div key={session.id} className="flex items-center justify-between text-sm p-2 border rounded">
<span className="text-muted-foreground font-mono">{session.device_network_membership_id}</span>
<div className="flex items-center gap-3">
<span className="text-muted-foreground">Expires: {formatExpiry(session.expires_at)}</span>
<Button size="sm" variant="ghost" onClick={() => handleDeactivate(session.id)} disabled={deactivatingId === session.id}>
{deactivatingId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
</Button>
{sessions.filter((s) => s.is_active).map((session) => {
const membership = getMembershipBySession(session);
return (
<div key={session.id} className="flex items-center gap-4 p-3 border rounded">
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0">
<Zap className="w-4 h-4 text-green-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{session.device && (
<Badge variant="outline" className="text-xs font-mono">
{session.device.name || session.device.node_id}
</Badge>
)}
{session.network && (
<Badge variant="secondary" className="text-xs">
{session.network.name}
</Badge>
)}
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span>Activated: {formatDate(session.authenticated_at)}</span>
<span className="text-green-600 font-medium flex items-center gap-1">
<Clock className="w-3 h-3" />
{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"}
</span>
</div>
</div>
{membership && (
<Button
size="sm"
variant="outline"
className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0"
onClick={() => handleDeactivate(membership.id)}
disabled={deactivatingId === membership.id}
>
{deactivatingId === membership.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
Deactivate
</Button>
)}
</div>
</div>
))}
);
})}
</div>
</CardContent>
</Card>
+3 -145
View File
@@ -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<string, string> = {
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 (
<Badge variant="outline" className={cn("text-xs", colors[state] || "bg-gray-100 text-gray-600")}>
{state.charAt(0).toUpperCase() + state.slice(1)}
</Badge>
);
}
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<UserNetworkApproval[]>([]);
const [isRequestsLoading, setIsRequestsLoading] = useState(false);
const [requestsError, setRequestsError] = useState<string | null>(null);
const [approvingRequest, setApprovingRequest] = useState<string | null>(null);
const [rejectingRequest, setRejectingRequest] = useState<string | null>(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() {
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="requests">Requests</TabsTrigger>
</TabsList>
<TabsContent value="overview">
@@ -758,79 +688,7 @@ export default function NetworkManagementPage() {
)}
</TabsContent>
<TabsContent value="requests">
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<Clock className="w-5 h-5" />
Access Requests
</span>
<Badge variant="secondary">{requests.length} pending</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{isRequestsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading requests</span>
</div>
) : requestsError ? (
<div className="p-6 text-center text-destructive">{requestsError}</div>
) : requests.length === 0 ? (
<div className="p-6 text-center text-muted-foreground">No pending requests for this network.</div>
) : (
<div className="divide-y">
{requests.map((r) => (
<div key={r.id} className="flex items-start gap-4 p-4">
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
<Users className="w-4 h-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium text-sm">{r.user_name || r.user_id}</p>
<ApprovalStateBadge state={r.state} />
<Badge variant="outline" className="text-xs capitalize">{r.grant_type}</Badge>
</div>
{r.user_email && (
<p className="text-xs text-muted-foreground">{r.user_email}</p>
)}
{r.justification && (
<p className="text-xs text-muted-foreground mt-1">"{r.justification}"</p>
)}
<p className="text-xs text-muted-foreground mt-1">
Requested: {formatDate(r.created_at)}
</p>
</div>
{r.state === "pending" && (
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="default"
size="sm"
onClick={() => handleApprove(r.id)}
disabled={approvingRequest === r.id}
>
{approvingRequest === r.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleReject(r.id)}
disabled={rejectingRequest === r.id}
>
{rejectingRequest === r.id && <Loader2 className="w-3 h-3 mr-1 animate-spin" />}
Reject
</Button>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Add New Membership Dialog */}