wulf-pulse/components/admin/users/user-sessions.tsx
lorentz 8f955a0ff9 feat(07.1-05): user-tz on shared client components
- DetailModal: thread tz through resolveLabel(...) module helper +
  default export's 3 inline date/time calls.
- IntegrationStatusTabs: thread tz through fmtDate helper +
  VeeamTab sub-component prop.
- SyncScheduler: thread tz into closure-scoped formatDate helper.
- audit-log-table, user-table, user-sessions, active-sessions: inline
  toLocale calls in component body.
- analysis-view: useUserTimezone in AnalysisView; thread tz into 4
  toLocaleString calls.
- resolution-trend, volume-trend (recharts): module-scope fmtDate(iso)
  → fmtDate(iso, tz); useUserTimezone in named export; thread tz into
  axis tickFormatter + tooltip labelFormatter.
- ticket-detail-modal: thread tz into formatDate arrow inside
  TicketDetailModal.
- TimelineView: useUserTimezone; thread tz into 4 toLocale*String calls
  (hour/day/month/event-time formatters).
- ScoreCard: useUserTimezone in AggregateScoreCard; thread tz into the
  date-range latest call.
- addigy-tab: useUserTimezone in AddigyTab; thread tz into 2 inline calls.
- activity-sparkline: module-scope fmtHour(iso) → fmtHour(iso, tz);
  useUserTimezone in ActivitySparkline; update 3 callsites in title/aria.
- compliance-detail-table: thread tz from ComplianceDetailTable into
  ContractCoverageModal sub-component (2 inline date calls).
- company-backup-detail: module-scope formatDate(d) → formatDate(d, tz);
  useUserTimezone in CompanyBackupDetail; update 3 callsites.

Migrates 31 of 81 audit leak callsites.
2026-05-07 08:43:27 -04:00

218 lines
6.8 KiB
TypeScript

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Trash2, Monitor, Smartphone, Globe } from "lucide-react";
import { toast } from "sonner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useUserTimezone } from "@/lib/hooks/use-user-timezone";
interface Session {
id: string;
ip_address: string;
user_agent: string;
created_at: string;
expires_at: string;
}
interface UserSessionsProps {
sessions: Session[];
userId: string;
}
function parseUserAgent(ua: string): { device: string; browser: string } {
const isMobile = /mobile|android|iphone|ipad/i.test(ua);
const device = isMobile ? "Mobile" : "Desktop";
let browser = "Unknown";
if (ua.includes("Chrome")) browser = "Chrome";
else if (ua.includes("Firefox")) browser = "Firefox";
else if (ua.includes("Safari")) browser = "Safari";
else if (ua.includes("Edge")) browser = "Edge";
return { device, browser };
}
export function UserSessions({ sessions, userId }: UserSessionsProps) {
const tz = useUserTimezone();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [showRevokeAllDialog, setShowRevokeAllDialog] = useState(false);
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null);
async function handleRevokeSession(sessionId: string) {
setRevokingSessionId(sessionId);
try {
const response = await fetch(`/api/admin/users/${userId}/sessions/${sessionId}`, {
method: "DELETE",
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to revoke session");
}
toast.success("Session revoked");
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "An error occurred");
} finally {
setRevokingSessionId(null);
}
}
async function handleRevokeAllSessions() {
setIsLoading(true);
try {
const response = await fetch(`/api/admin/users/${userId}/sessions`, {
method: "DELETE",
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "Failed to revoke sessions");
}
toast.success("All sessions revoked");
router.refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : "An error occurred");
} finally {
setIsLoading(false);
setShowRevokeAllDialog(false);
}
}
if (sessions.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No active sessions
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<Button
variant="destructive"
size="sm"
onClick={() => setShowRevokeAllDialog(true)}
disabled={isLoading}
>
Revoke All Sessions
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Created</TableHead>
<TableHead>Expires</TableHead>
<TableHead className="w-[100px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => {
const { device, browser } = parseUserAgent(session.user_agent || "");
const isExpired = new Date(session.expires_at) < new Date();
return (
<TableRow key={session.id}>
<TableCell>
<div className="flex items-center gap-2">
{device === "Mobile" ? (
<Smartphone className="h-4 w-4 text-muted-foreground" />
) : (
<Monitor className="h-4 w-4 text-muted-foreground" />
)}
<span>{browser}</span>
{isExpired && (
<Badge variant="outline" className="text-xs">Expired</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
{session.ip_address || "Unknown"}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(session.created_at).toLocaleString(undefined, { timeZone: tz })}
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(session.expires_at).toLocaleString(undefined, { timeZone: tz })}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => handleRevokeSession(session.id)}
disabled={revokingSessionId === session.id}
>
{revokingSessionId === session.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<AlertDialog open={showRevokeAllDialog} onOpenChange={setShowRevokeAllDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revoke All Sessions</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to revoke all sessions for this user? They will
be signed out from all devices.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevokeAllSessions}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Revoking...
</>
) : (
"Revoke All"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}