Removed requeets form netweorks tab
Actiev sessions now refreshes every 5 seconds Better session mgmt
This commit is contained in:
+51
-6
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user