diff --git a/src/App.tsx b/src/App.tsx index ba357e8..4675ed7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -61,6 +61,7 @@ import SystemAuditPage from "@/pages/admin/SystemAuditPage"; import OAuthProvidersPage from "@/pages/admin/OAuthProvidersPage"; import OrgSetupPage from "@/pages/auth/OrgSetupPage"; +import SessionTimeoutModal from "@/components/auth/SessionTimeoutModal"; import NotFound from "@/pages/NotFound"; const queryClient = new QueryClient({ @@ -151,6 +152,7 @@ function RequireAuth({ children }: { children: React.ReactNode }) { function AppRoutes() { return ( + {/* Marketing pages */} diff --git a/src/components/auth/SessionTimeoutModal.tsx b/src/components/auth/SessionTimeoutModal.tsx new file mode 100644 index 0000000..e654b03 --- /dev/null +++ b/src/components/auth/SessionTimeoutModal.tsx @@ -0,0 +1,50 @@ +import { useEffect, useState, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { tokenManager } from '@/lib/api'; + +export default function SessionTimeoutModal() { + const [open, setOpen] = useState(false); + const navigate = useNavigate(); + + const handleOpenChange = useCallback((isOpen: boolean) => { + if (!isOpen) { + tokenManager.clearToken(); + navigate('/login', { replace: true }); + } + }, [navigate]); + + useEffect(() => { + const onSessionExpired = () => setOpen(true); + window.addEventListener('session:expired', onSessionExpired); + return () => window.removeEventListener('session:expired', onSessionExpired); + }, []); + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + + Session Expired + + Your session has timed out. Please sign in again to continue. + + +
+ +
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 0d76b71..5d1c9ab 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -434,6 +434,8 @@ async function request( if (shouldClearToken) { tokenManager.clearToken(); + // Dispatch event so the UI can show a session timeout modal + window.dispatchEvent(new CustomEvent('session:expired')); if (import.meta.env.DEV) { console.log(`[API] Token cleared on 401 (type: ${errorType}, endpoint: ${endpoint})`); } @@ -2114,9 +2116,13 @@ export interface UserNetworkApproval { user_name: string | null; user_email: string | null; portal_network_id: string; + device_id?: string; + active?: boolean; + active_session?: ActivationSession | null; + join_seen?: boolean; granted_by_user_id: string | null; grant_type: ApprovalGrantType; - state: ApprovalState; + status: ApprovalState; justification: string | null; created_at: string; updated_at: string; diff --git a/src/pages/org/AccessPage.tsx b/src/pages/org/AccessPage.tsx index 1561da6..c493535 100644 --- a/src/pages/org/AccessPage.tsx +++ b/src/pages/org/AccessPage.tsx @@ -123,10 +123,13 @@ export default function AccessPage() { const [search, setSearch] = useState(""); const [selectedNetworkFilter, setSelectedNetworkFilter] = useState("all"); + const [approvalStateFilter, setApprovalStateFilter] = useState("all"); + const [approveId, setApproveId] = useState(null); const [rejectId, setRejectId] = useState(null); const [revokeId, setRevokeId] = useState(null); const [isApproving, setIsApproving] = useState(false); + const [rejectConfirmId, setRejectConfirmId] = useState(null); const [showAssign, setShowAssign] = useState(false); const [assignUserId, setAssignUserId] = useState(""); @@ -384,6 +387,17 @@ export default function AccessPage() { return true; }); + const filteredApprovals = approvals.filter((a) => { + if (approvalStateFilter !== "all" && a.state !== approvalStateFilter) return false; + if (selectedNetworkFilter !== "all" && a.portal_network_id !== selectedNetworkFilter) return false; + if (search) { + const q = search.toLowerCase(); + const display = getUserDisplay(a.user_id).toLowerCase(); + if (!display.includes(q)) return false; + } + return true; + }); + const filteredSessions = sessions.filter((s) => s.is_active); const activeSessions = filteredSessions; @@ -484,7 +498,7 @@ export default function AccessPage() {

{getUserDisplay(approval.user_id)}

{getNetworkName(approval.portal_network_id)} - +

{approval.grant_type === "requested" ? "User request" : "Manager assignment"} @@ -603,10 +617,25 @@ export default function AccessPage() { - - - All Approvals - +

+ + + All Approvals + + +
Complete history of network access grants @@ -615,41 +644,129 @@ export default function AccessPage() { Loading… - ) : approvals.length === 0 ? ( -
No approvals found.
+ ) : filteredApprovals.length === 0 ? ( +
+ {approvalStateFilter !== "all" || search || selectedNetworkFilter !== "all" + ? "No approvals match your filters." + : "No approvals found."} +
) : ( -
- {approvals.map((approval) => ( -
-
-
-

{getUserDisplay(approval.user_id)}

- {getNetworkName(approval.portal_network_id)} - -
-

- {approval.grant_type === "requested" ? "User request" : "Manager assignment"} - {approval.justification && ` — "${approval.justification}"`} -

-

- {formatDate(approval.created_at)} - {approval.granted_by_user_id && ` · Granted by: ${getUserDisplay(approval.granted_by_user_id)}`} -

-
- {(approval.state === "approved" || approval.state === "suspended") && ( - - )} -
- ))} +
+ + + + + + + + + + + + + + {filteredApprovals.map((approval) => ( + + + + + + + + + + ))} + +
UserDeviceNetworkStatusRequestedApprovedActions
+
+

{getUserDisplay(approval.user_id)}

+ {approval.justification && ( +

+ "{approval.justification}" +

+ )} +
+
+ {approval.device_id ? ( + {approval.device_id} + ) : ( + + )} + + {getNetworkName(approval.portal_network_id)} + + + + {formatDate(approval.created_at)} + + {approval.status !== "pending" ? ( +
+ {formatDate(approval.updated_at)} + {approval.granted_by_user_id && ( +

by {getUserDisplay(approval.granted_by_user_id)}

+ )} +
+ ) : ( + + )} +
+ {approval.status === "pending" || approval.status === "approved" || approval.status === "suspended" ? ( + + + + + + {approval.status === "pending" && ( + <> + handleApprove(approval.id)} + disabled={approveId === approval.id || isApproving} + className="text-green-600 focus:text-green-700" + > + + Approve + + setRejectConfirmId(approval.id)} + disabled={rejectId === approval.id || isApproving} + className="text-red-600 focus:text-red-700" + > + + Reject + + + )} + {(approval.status === "approved" || approval.status === "suspended") && ( + handleRevoke(approval.id)} + disabled={revokeId === approval.id || isApproving} + className="text-red-600 focus:text-red-700" + > + + Revoke + + )} + + + ) : ( + + )} +
)} @@ -898,6 +1015,39 @@ export default function AccessPage() { + {/* Reject Confirmation Dialog */} + { if (!open) setRejectConfirmId(null); }}> + + + Reject Request + + Are you sure you want to reject this access request? This action cannot be undone. + + +
+
+ + The request will be permanently rejected. The user will need to submit a new request if they want access in the future. +
+
+ + + + +
+
+ {/* End Session Confirmation Dialog */} { if (!open) { setShowEndSessionConfirm(false); setEndSessionTarget(null); } }}> diff --git a/src/pages/org/DevicesPage.tsx b/src/pages/org/DevicesPage.tsx index 64065c4..15f8774 100644 --- a/src/pages/org/DevicesPage.tsx +++ b/src/pages/org/DevicesPage.tsx @@ -844,7 +844,7 @@ export default function DevicesPage() {

{network.zerotier_network_id}

{approval && (
- + {approval.justification && ( "{approval.justification}" )} @@ -990,7 +990,7 @@ export default function DevicesPage() {

{network?.name || approval.portal_network_id}

{network?.environment} - +

{approval.grant_type === "requested" ? "You requested" : "Assigned by admin"} @@ -1013,7 +1013,7 @@ export default function DevicesPage() {

)}
- {approval.state === "pending" && ( + {approval.status === "pending" && (