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 ──────────────────────────────────────────────────────────────
|
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||||
listSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
listUserSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ sessions: ActivationSession[]; count: number }>(
|
request<{ sessions: UserSession[]; count: number }>(
|
||||||
`/organizations/${orgId}/sessions`, {}, true, requestConfig,
|
`/organizations/${orgId}/sessions`, {}, true, requestConfig,
|
||||||
),
|
),
|
||||||
|
|
||||||
endSession: (orgId: string, sessionId: string, requestConfig?: RequestConfig) =>
|
adminListSessions: (orgId: string, requestConfig?: RequestConfig) =>
|
||||||
request<{ message: string }>(
|
request<{ sessions: AdminSession[]; count: number }>(
|
||||||
`/organizations/${orgId}/sessions/${sessionId}`,
|
`/organizations/${orgId}/admin/sessions`, {}, true, requestConfig,
|
||||||
{ method: "DELETE" }, 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 ───────────────────────────────────────────────────────────
|
// ── Kill Switch ───────────────────────────────────────────────────────────
|
||||||
@@ -2193,6 +2198,46 @@ export interface ActivationSession {
|
|||||||
is_active: boolean;
|
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 {
|
export interface KillSwitchEvent {
|
||||||
id: string;
|
id: string;
|
||||||
organization_id: string;
|
organization_id: string;
|
||||||
|
|||||||
+102
-15
@@ -57,7 +57,7 @@ import {
|
|||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
UserNetworkApproval,
|
UserNetworkApproval,
|
||||||
ActivationSession,
|
AdminSession,
|
||||||
KillSwitchEvent,
|
KillSwitchEvent,
|
||||||
PortalNetwork,
|
PortalNetwork,
|
||||||
OrganizationMember,
|
OrganizationMember,
|
||||||
@@ -114,7 +114,7 @@ export default function AccessPage() {
|
|||||||
|
|
||||||
const [approvals, setApprovals] = useState<UserNetworkApproval[]>([]);
|
const [approvals, setApprovals] = useState<UserNetworkApproval[]>([]);
|
||||||
const [pendingApprovals, setPendingApprovals] = useState<UserNetworkApproval[]>([]);
|
const [pendingApprovals, setPendingApprovals] = useState<UserNetworkApproval[]>([]);
|
||||||
const [sessions, setSessions] = useState<ActivationSession[]>([]);
|
const [sessions, setSessions] = useState<AdminSession[]>([]);
|
||||||
const [killSwitchEvents, setKillSwitchEvents] = useState<KillSwitchEvent[]>([]);
|
const [killSwitchEvents, setKillSwitchEvents] = useState<KillSwitchEvent[]>([]);
|
||||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||||
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
|
const [orgMembers, setOrgMembers] = useState<OrganizationMember[]>([]);
|
||||||
@@ -143,7 +143,8 @@ export default function AccessPage() {
|
|||||||
const [killError, setKillError] = useState<string | null>(null);
|
const [killError, setKillError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [endSessionId, setEndSessionId] = 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 [selectedApproval, setSelectedApproval] = useState<UserNetworkApproval | null>(null);
|
||||||
const [allMemberships, setAllMemberships] = useState<EnrichedMembership[]>([]);
|
const [allMemberships, setAllMemberships] = useState<EnrichedMembership[]>([]);
|
||||||
@@ -164,7 +165,7 @@ export default function AccessPage() {
|
|||||||
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
|
const [pendingRes, allApprovalsRes, sessionsRes, networksRes, membersRes, allMemsRes] = await Promise.allSettled([
|
||||||
api.zerotier.listPendingApprovals(orgId),
|
api.zerotier.listPendingApprovals(orgId),
|
||||||
api.zerotier.adminListAllApprovals(orgId),
|
api.zerotier.adminListAllApprovals(orgId),
|
||||||
api.zerotier.listSessions(orgId),
|
api.zerotier.adminListSessions(orgId),
|
||||||
api.zerotier.listNetworks(orgId),
|
api.zerotier.listNetworks(orgId),
|
||||||
api.organizations.getMembers(orgId),
|
api.organizations.getMembers(orgId),
|
||||||
api.zerotier.adminListAllMemberships(orgId),
|
api.zerotier.adminListAllMemberships(orgId),
|
||||||
@@ -188,6 +189,21 @@ export default function AccessPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [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) => {
|
const handleApprove = async (approvalId: string) => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setApproveId(approvalId);
|
setApproveId(approvalId);
|
||||||
@@ -278,18 +294,42 @@ export default function AccessPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndSession = async (sessionId: string) => {
|
const handleEndSession = (session: AdminSession) => {
|
||||||
if (!orgId) return;
|
setEndSessionTarget(session);
|
||||||
|
setShowEndSessionConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndSessionConfirm = async () => {
|
||||||
|
if (!orgId || !endSessionTarget) return;
|
||||||
|
const sessionId = endSessionTarget.id;
|
||||||
setEndSessionId(sessionId);
|
setEndSessionId(sessionId);
|
||||||
setIsEndingSession(true);
|
setShowEndSessionConfirm(false);
|
||||||
try {
|
try {
|
||||||
await api.zerotier.endSession(orgId, sessionId);
|
const res = await api.zerotier.adminEndSession(orgId, sessionId);
|
||||||
toast({ title: "Session ended" });
|
setSessions((prev) =>
|
||||||
fetchData();
|
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) {
|
} 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 {
|
} finally {
|
||||||
setEndSessionId(null);
|
setEndSessionId(null);
|
||||||
|
setEndSessionTarget(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -510,12 +550,34 @@ export default function AccessPage() {
|
|||||||
<Zap className="w-4 h-4 text-green-500" />
|
<Zap className="w-4 h-4 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-medium font-mono truncate">{session.device_network_membership_id}</p>
|
<p className="font-medium truncate">{session.user?.full_name || session.user?.email || "Unknown user"}</p>
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
{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>Activated: {formatDate(session.authenticated_at)}</span>
|
||||||
<span className="text-green-600 font-medium flex items-center gap-1">
|
<span className="text-green-600 font-medium flex items-center gap-1">
|
||||||
<Clock className="w-3 h-3" />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -523,7 +585,7 @@ export default function AccessPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-orange-600 border-orange-300 hover:bg-orange-50 gap-1 flex-shrink-0"
|
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}
|
disabled={endSessionId === session.id}
|
||||||
>
|
>
|
||||||
{endSessionId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
{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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ import {
|
|||||||
ApiError,
|
ApiError,
|
||||||
Device,
|
Device,
|
||||||
DeviceNetworkMembership,
|
DeviceNetworkMembership,
|
||||||
ActivationSession,
|
UserSession,
|
||||||
MembershipState,
|
MembershipState,
|
||||||
PortalNetwork,
|
PortalNetwork,
|
||||||
UserNetworkApproval,
|
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 now = Date.now();
|
||||||
const expires = new Date(session.expires_at).getTime();
|
const expires = new Date(session.expires_at).getTime();
|
||||||
const created = new Date(session.authenticated_at).getTime();
|
const created = new Date(session.authenticated_at).getTime();
|
||||||
@@ -195,7 +195,7 @@ export default function DevicesPage() {
|
|||||||
|
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
const [memberships, setMemberships] = useState<DeviceNetworkMembership[]>([]);
|
const [memberships, setMemberships] = useState<DeviceNetworkMembership[]>([]);
|
||||||
const [sessions, setSessions] = useState<ActivationSession[]>([]);
|
const [sessions, setSessions] = useState<UserSession[]>([]);
|
||||||
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
const [networks, setNetworks] = useState<PortalNetwork[]>([]);
|
||||||
const [myApprovals, setMyApprovals] = useState<UserNetworkApproval[]>([]);
|
const [myApprovals, setMyApprovals] = useState<UserNetworkApproval[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -246,7 +246,7 @@ export default function DevicesPage() {
|
|||||||
const [devicesRes, membershipsRes, sessionsRes, networksRes, approvalsRes] = await Promise.allSettled([
|
const [devicesRes, membershipsRes, sessionsRes, networksRes, approvalsRes] = await Promise.allSettled([
|
||||||
api.zerotier.listDevices(orgId),
|
api.zerotier.listDevices(orgId),
|
||||||
api.zerotier.listMemberships(orgId),
|
api.zerotier.listMemberships(orgId),
|
||||||
api.zerotier.listSessions(orgId),
|
api.zerotier.listUserSessions(orgId),
|
||||||
api.zerotier.listNetworks(orgId),
|
api.zerotier.listNetworks(orgId),
|
||||||
api.zerotier.listMyApprovals(orgId),
|
api.zerotier.listMyApprovals(orgId),
|
||||||
]);
|
]);
|
||||||
@@ -268,6 +268,21 @@ export default function DevicesPage() {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [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 () => {
|
const handleRegister = async () => {
|
||||||
if (!orgId) return;
|
if (!orgId) return;
|
||||||
setRegError(null);
|
setRegError(null);
|
||||||
@@ -434,14 +449,19 @@ export default function DevicesPage() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const getActiveSession = (membershipId: string): ActivationSession | null => {
|
const getActiveSession = (deviceId: string, networkId: string): UserSession | null => {
|
||||||
return sessions.find((s) => s.device_network_membership_id === membershipId && s.is_active) ?? 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 => {
|
const getMembershipForDeviceAndNetwork = (deviceId: string, networkId: string): DeviceNetworkMembership | null => {
|
||||||
return memberships.find((m) => m.device_id === deviceId && m.portal_network_id === networkId) ?? 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 => {
|
const getApprovalForNetwork = (networkId: string): UserNetworkApproval | null => {
|
||||||
return myApprovals.find((a) => a.portal_network_id === networkId) ?? null;
|
return myApprovals.find((a) => a.portal_network_id === networkId) ?? null;
|
||||||
};
|
};
|
||||||
@@ -629,7 +649,7 @@ export default function DevicesPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{deviceMemberships.map((m) => {
|
{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);
|
const network = networks.find((n) => n.id === m.portal_network_id);
|
||||||
return (
|
return (
|
||||||
<div key={m.id} className="p-3 border rounded-lg space-y-2">
|
<div key={m.id} className="p-3 border rounded-lg space-y-2">
|
||||||
@@ -709,17 +729,56 @@ export default function DevicesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 space-y-1">
|
||||||
{sessions.filter((s) => s.is_active).map((session) => (
|
{sessions.filter((s) => s.is_active).map((session) => {
|
||||||
<div key={session.id} className="flex items-center justify-between text-sm p-2 border rounded">
|
const membership = getMembershipBySession(session);
|
||||||
<span className="text-muted-foreground font-mono">{session.device_network_membership_id}</span>
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div key={session.id} className="flex items-center gap-4 p-3 border rounded">
|
||||||
<span className="text-muted-foreground">Expires: {formatExpiry(session.expires_at)}</span>
|
<div className="w-8 h-8 rounded-full bg-green-500/10 flex items-center justify-center flex-shrink-0">
|
||||||
<Button size="sm" variant="ghost" onClick={() => handleDeactivate(session.id)} disabled={deactivatingId === session.id}>
|
<Zap className="w-4 h-4 text-green-500" />
|
||||||
{deactivatingId === session.id ? <Loader2 className="w-3 h-3 animate-spin" /> : <ZapOff className="w-3 h-3" />}
|
</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>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
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 { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import {
|
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() {
|
export default function NetworkManagementPage() {
|
||||||
const { networkId } = useParams<{ networkId: string }>();
|
const { networkId } = useParams<{ networkId: string }>();
|
||||||
const { orgId } = useCurrentOrganizationId();
|
const { orgId } = useCurrentOrganizationId();
|
||||||
@@ -148,12 +133,6 @@ export default function NetworkManagementPage() {
|
|||||||
const [activateLifetime, setActivateLifetime] = useState("480");
|
const [activateLifetime, setActivateLifetime] = useState("480");
|
||||||
const [activatingAll, setActivatingAll] = useState(false);
|
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
|
// Add New Membership dialog state
|
||||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
const [addStep, setAddStep] = useState<1 | 2 | 3>(1);
|
const [addStep, setAddStep] = useState<1 | 2 | 3>(1);
|
||||||
@@ -210,23 +189,6 @@ export default function NetworkManagementPage() {
|
|||||||
fetchMembers();
|
fetchMembers();
|
||||||
}, [orgId, networkId]);
|
}, [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
|
// Fetch org members when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAddDialogOpen || !orgId) return;
|
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 ──────────────────────────────────────────────────────────
|
// ── SKELETON STATE ──────────────────────────────────────────────────────────
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -486,7 +417,6 @@ export default function NetworkManagementPage() {
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
<TabsTrigger value="members">Members</TabsTrigger>
|
<TabsTrigger value="members">Members</TabsTrigger>
|
||||||
<TabsTrigger value="requests">Requests</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview">
|
<TabsContent value="overview">
|
||||||
@@ -758,79 +688,7 @@ export default function NetworkManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</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>
|
</Tabs>
|
||||||
|
|
||||||
{/* Add New Membership Dialog */}
|
{/* Add New Membership Dialog */}
|
||||||
|
|||||||
Reference in New Issue
Block a user