790 lines
35 KiB
TypeScript
790 lines
35 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Shield,
|
|
Monitor,
|
|
Smartphone,
|
|
Tablet,
|
|
Loader2,
|
|
AlertTriangle,
|
|
Radio,
|
|
Flame,
|
|
ShieldAlert,
|
|
KeyRound,
|
|
RefreshCw,
|
|
Search,
|
|
Globe,
|
|
Clock,
|
|
X,
|
|
Copy,
|
|
Check,
|
|
ShieldOff,
|
|
} from 'lucide-react';
|
|
import { adminService, SessionInfo, AuditLogEntry } from '@/services/api/adminService';
|
|
import { parseJwt, getAccessToken } from '@/services/api/client';
|
|
import { format } from 'date-fns';
|
|
import { toast } from 'sonner';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
import { StatsSparklineCard, datesToSparkline, weekOverWeekChange } from '@/components/ui/StatsSparklineCard';
|
|
|
|
type DeviceFilter = 'all' | 'desktop' | 'mobile' | 'tablet';
|
|
type InsightTab = 'sessions' | 'history' | 'failed';
|
|
|
|
function normalizeDeviceType(type: string): DeviceFilter {
|
|
const value = (type || '').toLowerCase();
|
|
if (value.includes('mobile') || value.includes('phone')) return 'mobile';
|
|
if (value.includes('tablet')) return 'tablet';
|
|
return 'desktop';
|
|
}
|
|
|
|
function deviceTone(type: DeviceFilter) {
|
|
if (type === 'mobile') {
|
|
return {
|
|
box: 'bg-success/10 text-success',
|
|
bar: 'bg-success',
|
|
badge: 'bg-success text-white',
|
|
};
|
|
}
|
|
if (type === 'tablet') {
|
|
return {
|
|
box: 'bg-warning/10 text-warning',
|
|
bar: 'bg-warning',
|
|
badge: 'bg-warning text-white',
|
|
};
|
|
}
|
|
return {
|
|
box: 'bg-muted text-foreground',
|
|
bar: 'bg-muted-foreground',
|
|
badge: 'bg-muted text-foreground',
|
|
};
|
|
}
|
|
|
|
function remainingLabel(expiresAt: string) {
|
|
const ms = new Date(expiresAt).getTime() - Date.now();
|
|
if (Number.isNaN(ms) || ms <= 0) return { text: 'Expired', urgent: true };
|
|
const mins = Math.floor(ms / 60000);
|
|
if (mins < 60) return { text: `${mins}m left`, urgent: true };
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return { text: `${hours}h ${mins % 60}m left`, urgent: hours < 2 };
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 7) return { text: `${days}d left`, urgent: false };
|
|
return { text: 'Active', urgent: false };
|
|
}
|
|
|
|
export default function SecurityPage() {
|
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
const [failedLogins, setFailedLogins] = useState<AuditLogEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [revoking, setRevoking] = useState<string | null>(null);
|
|
const [mfaCode, setMfaCode] = useState('');
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
|
const [deviceFilter, setDeviceFilter] = useState<DeviceFilter>('all');
|
|
const [deviceSearch, setDeviceSearch] = useState('');
|
|
const [insightTab, setInsightTab] = useState<InsightTab>('sessions');
|
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
|
const [resetPassword, setResetPassword] = useState('');
|
|
const [resetPhrase, setResetPhrase] = useState('');
|
|
const [copiedIp, setCopiedIp] = useState<string | null>(null);
|
|
const devicesRef = useRef<HTMLDivElement>(null);
|
|
const failedRef = useRef<HTMLDivElement>(null);
|
|
|
|
const pager = useClientPagination(failedLogins);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [sessionsData, failedData] = await Promise.allSettled([
|
|
adminService.listSessions(0, 100),
|
|
adminService.getFailedLogins(20),
|
|
]);
|
|
if (sessionsData.status === 'fulfilled') setSessions(sessionsData.value.sessions);
|
|
if (failedData.status === 'fulfilled') setFailedLogins(failedData.value);
|
|
} catch {
|
|
toast.error('Failed to load security data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
const token = getAccessToken();
|
|
if (token) {
|
|
const payload = parseJwt(token);
|
|
if (payload && payload.sub) {
|
|
setCurrentUserId(payload.sub as string);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const handleRevoke = async (sessionId: string) => {
|
|
setRevoking(sessionId);
|
|
try {
|
|
await adminService.revokeSession(sessionId);
|
|
toast.success('Session ended on this device');
|
|
fetchData();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to revoke session';
|
|
toast.error(message);
|
|
} finally {
|
|
setRevoking(null);
|
|
}
|
|
};
|
|
|
|
const handleKillSwitch = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!currentUserId) {
|
|
toast.error('Could not determine current admin ID. Please re-authenticate.');
|
|
return;
|
|
}
|
|
if (!mfaCode.trim()) {
|
|
toast.error('Please enter your MFA verification code');
|
|
return;
|
|
}
|
|
|
|
setActionLoading(true);
|
|
try {
|
|
const res = await adminService.requestKillSwitch(currentUserId, mfaCode);
|
|
toast.success(res.detail || 'Kill switch executed successfully. All users logged out.');
|
|
setMfaCode('');
|
|
fetchData();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to execute kill switch';
|
|
toast.error(message);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelKillSwitch = async () => {
|
|
if (!currentUserId) {
|
|
toast.error('Could not determine current admin ID. Please re-authenticate.');
|
|
return;
|
|
}
|
|
|
|
setActionLoading(true);
|
|
try {
|
|
const res = await adminService.cancelKillSwitch(currentUserId);
|
|
toast.success(res.detail || 'Kill switch cancelled. Access restored.');
|
|
fetchData();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to cancel kill switch';
|
|
toast.error(message);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSelfDestruct = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!currentUserId) {
|
|
toast.error('Could not determine current admin ID. Please re-authenticate.');
|
|
return;
|
|
}
|
|
if (!mfaCode.trim()) {
|
|
toast.error('Please enter your MFA verification code');
|
|
return;
|
|
}
|
|
|
|
setActionLoading(true);
|
|
try {
|
|
const res = await adminService.selfDestruct(currentUserId, mfaCode);
|
|
toast.success(res.detail || 'Self destruct completed. All sessions destroyed.');
|
|
setMfaCode('');
|
|
fetchData();
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to execute self-destruct';
|
|
toast.error(message);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleFactoryReset = async () => {
|
|
if (!resetPassword.trim()) {
|
|
toast.error('Enter your admin password');
|
|
return;
|
|
}
|
|
if (resetPhrase !== 'CONFIRM_FACTORY_RESET_WIPE_2026') {
|
|
toast.error('Invalid confirmation phrase');
|
|
return;
|
|
}
|
|
|
|
setActionLoading(true);
|
|
try {
|
|
const token = getAccessToken();
|
|
const res = await fetch(
|
|
`${process.env.NEXT_PUBLIC_API_URL || ''}/api/v1/admin/security/factory-reset`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ password: resetPassword, confirmation_phrase: resetPhrase }),
|
|
}
|
|
);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: 'Reset failed' }));
|
|
throw new Error(err.detail || 'Reset failed');
|
|
}
|
|
toast.success('Platform factory reset completed. Redirecting to login...');
|
|
setShowResetDialog(false);
|
|
localStorage.clear();
|
|
setTimeout(() => {
|
|
window.location.href = '/login';
|
|
}, 2000);
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Factory reset failed';
|
|
toast.error(message);
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const getDeviceIcon = (type: DeviceFilter, className = 'w-5 h-5') => {
|
|
if (type === 'mobile') return <Smartphone className={className} />;
|
|
if (type === 'tablet') return <Tablet className={className} />;
|
|
return <Monitor className={className} />;
|
|
};
|
|
|
|
const activeSessions = sessions.filter((s) => s.is_active);
|
|
const boardSessions = insightTab === 'history' ? sessions : activeSessions;
|
|
|
|
const deviceCounts = useMemo(() => {
|
|
const counts: Record<string, number> = { desktop: 0, mobile: 0, tablet: 0 };
|
|
for (const session of boardSessions) {
|
|
const devType = normalizeDeviceType(session.device_type);
|
|
counts[devType] = (counts[devType] || 0) + 1;
|
|
}
|
|
return counts;
|
|
}, [boardSessions]);
|
|
|
|
const filteredDevices = useMemo(() => {
|
|
const q = deviceSearch.trim().toLowerCase();
|
|
return boardSessions.filter((session) => {
|
|
const kind = normalizeDeviceType(session.device_type);
|
|
const matchesType = deviceFilter === 'all' || kind === deviceFilter;
|
|
const haystack = [session.browser, session.operating_system, session.device_name, session.ip_address]
|
|
.join(' ')
|
|
.toLowerCase();
|
|
return matchesType && (!q || haystack.includes(q));
|
|
});
|
|
}, [boardSessions, deviceFilter, deviceSearch]);
|
|
|
|
const devicePager = useClientPagination(filteredDevices, 6);
|
|
const mixTotal = boardSessions.length || 1;
|
|
|
|
const copyIp = async (ip: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(ip);
|
|
setCopiedIp(ip);
|
|
toast.success('IP address copied');
|
|
setTimeout(() => setCopiedIp(null), 1500);
|
|
} catch {
|
|
toast.error('Could not copy IP address');
|
|
}
|
|
};
|
|
|
|
const openInsight = (tab: InsightTab) => {
|
|
setInsightTab(tab);
|
|
const node = tab === 'failed' ? failedRef.current : devicesRef.current;
|
|
node?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
};
|
|
|
|
const dataCardShell =
|
|
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-visible min-w-0';
|
|
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
|
|
const primaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 min-h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50 whitespace-nowrap';
|
|
const secondaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 min-h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50 whitespace-nowrap';
|
|
const inputClass =
|
|
'w-full h-9 pl-8 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">Security & Sessions</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
|
|
{activeSessions.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={fetchData}
|
|
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
title="Reload"
|
|
>
|
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<StatsSparklineCard
|
|
title="Active sessions"
|
|
value={loading ? '—' : activeSessions.length}
|
|
icon={Radio}
|
|
tone="green"
|
|
series={datesToSparkline(activeSessions.map((s) => s.expires_at))}
|
|
deltaPercent={weekOverWeekChange(activeSessions.map((s) => s.expires_at))}
|
|
active={insightTab === 'sessions'}
|
|
onClick={() => openInsight('sessions')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Session history"
|
|
value={loading ? '—' : sessions.length}
|
|
icon={Monitor}
|
|
tone="blue"
|
|
series={datesToSparkline(sessions.map((s) => s.expires_at))}
|
|
deltaPercent={weekOverWeekChange(sessions.map((s) => s.expires_at))}
|
|
active={insightTab === 'history'}
|
|
onClick={() => openInsight('history')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Failed logins"
|
|
value={loading ? '—' : failedLogins.length}
|
|
icon={AlertTriangle}
|
|
tone="orange"
|
|
series={datesToSparkline(failedLogins.map((l) => l.created_at))}
|
|
deltaPercent={weekOverWeekChange(failedLogins.map((l) => l.created_at))}
|
|
active={insightTab === 'failed'}
|
|
onClick={() => openInsight('failed')}
|
|
/>
|
|
</div>
|
|
|
|
<div ref={devicesRef} className={dataCardShell}>
|
|
<div className="p-4 border-b border-border">
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-[16px] font-semibold text-foreground">
|
|
{insightTab === 'history' ? 'Session history' : 'Active connected devices'}
|
|
</h2>
|
|
{insightTab !== 'history' && (
|
|
<span className="inline-flex items-center gap-1.5 h-5 px-2 crm-radius-toggle bg-success/10 text-success text-[11px] font-semibold">
|
|
<span className="relative flex h-1.5 w-1.5">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-60" />
|
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-success" />
|
|
</span>
|
|
Live {activeSessions.length}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mt-1">
|
|
{insightTab === 'history'
|
|
? 'Every recorded admin session, including devices that have already signed out.'
|
|
: 'Devices currently holding an open admin session.'}
|
|
</p>
|
|
</div>
|
|
<div className="relative w-full sm:w-[220px]">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search IP, browser, OS"
|
|
value={deviceSearch}
|
|
onChange={(e) => setDeviceSearch(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-2 xl:grid-cols-4 gap-2">
|
|
{([
|
|
{ id: 'all' as DeviceFilter, label: 'All devices', count: boardSessions.length, icon: Radio, tone: 'bg-primary/10 text-primary', bar: 'bg-primary' },
|
|
{ id: 'desktop' as DeviceFilter, label: 'Desktop', count: deviceCounts.desktop, icon: Monitor, tone: deviceTone('desktop').box, bar: deviceTone('desktop').bar },
|
|
{ id: 'mobile' as DeviceFilter, label: 'Mobile', count: deviceCounts.mobile, icon: Smartphone, tone: deviceTone('mobile').box, bar: deviceTone('mobile').bar },
|
|
{ id: 'tablet' as DeviceFilter, label: 'Tablet', count: deviceCounts.tablet, icon: Tablet, tone: deviceTone('tablet').box, bar: deviceTone('tablet').bar },
|
|
]).map((tile) => {
|
|
const selected = deviceFilter === tile.id;
|
|
const Icon = tile.icon;
|
|
const share = mixTotal ? (tile.count / mixTotal) * 100 : 0;
|
|
return (
|
|
<button
|
|
key={tile.id}
|
|
type="button"
|
|
onClick={() => setDeviceFilter(tile.id)}
|
|
className={`text-left p-3 crm-radius-section border bg-card cursor-pointer transition-colors min-w-0 ${
|
|
selected ? 'border-[#b8c0d4]' : 'border-border hover:bg-muted/40'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className={`w-8 h-8 crm-radius-icon flex items-center justify-center shrink-0 ${tile.tone}`}>
|
|
<Icon className="w-4 h-4" />
|
|
</span>
|
|
<span className="text-[20px] font-bold text-foreground leading-none tabular-nums">{tile.count}</span>
|
|
</div>
|
|
<p className="text-[12px] font-medium text-muted-foreground mt-2">{tile.label}</p>
|
|
<div className="mt-2 h-1.5 crm-radius-toggle overflow-hidden bg-muted">
|
|
<div className={`h-full ${tile.bar}`} style={{ width: `${share}%` }} />
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="py-16 flex justify-center">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
|
</div>
|
|
) : filteredDevices.length === 0 ? (
|
|
<div className="py-16 text-center text-[13px] text-muted-foreground">
|
|
{deviceSearch || deviceFilter !== 'all' ? 'No connected devices match your search.' : 'No sessions found.'}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3 p-4">
|
|
{devicePager.items.map((session) => {
|
|
const kind = normalizeDeviceType(session.device_type);
|
|
const tone = deviceTone(kind);
|
|
const yours = Boolean(currentUserId && session.user_id === currentUserId);
|
|
const remaining = remainingLabel(session.expires_at);
|
|
const live = session.is_active;
|
|
return (
|
|
<article
|
|
key={session.session_id}
|
|
className={`flex min-w-0 overflow-hidden crm-radius-section border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] ${
|
|
yours ? 'border-[#b8c0d4]' : 'border-border'
|
|
} ${live ? '' : 'opacity-80'}`}
|
|
>
|
|
<div className={`w-12 shrink-0 flex flex-col items-center justify-center gap-1.5 py-3 ${live ? tone.box : 'bg-muted text-muted-foreground'}`}>
|
|
<div className="relative">
|
|
{getDeviceIcon(kind, 'w-5 h-5')}
|
|
{live && (
|
|
<span className="absolute -top-1 -right-1 flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-60" />
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-success border-2 border-card" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-[9px] font-semibold uppercase tracking-wide leading-none">{kind}</span>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 p-3 flex flex-col overflow-hidden">
|
|
<div className="flex items-start justify-between gap-2 min-w-0">
|
|
<div className="min-w-0 overflow-hidden">
|
|
<p className="text-[13px] font-semibold text-foreground truncate">
|
|
{session.browser || 'Unknown browser'}
|
|
</p>
|
|
<p className="text-[12px] text-muted-foreground truncate">
|
|
{session.operating_system || 'Unknown OS'}
|
|
{session.device_name ? ` · ${session.device_name}` : ''}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{yours && (
|
|
<span className="inline-flex items-center px-1.5 py-0.5 crm-radius-badge bg-primary/10 text-primary text-[11px] font-semibold">
|
|
You
|
|
</span>
|
|
)}
|
|
<span
|
|
className={`inline-flex items-center px-1.5 py-0.5 crm-radius-badge text-[11px] font-semibold ${
|
|
live
|
|
? remaining.urgent
|
|
? 'bg-warning text-white'
|
|
: 'bg-success text-white'
|
|
: 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{live ? remaining.text : 'Ended'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2.5 border-t border-border pt-2 space-y-1.5 min-w-0">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground w-[58px] shrink-0">
|
|
<Globe className="w-3 h-3" /> IP
|
|
</span>
|
|
<span className="min-w-0 flex-1 flex items-center gap-1 overflow-hidden">
|
|
<span className="block min-w-0 flex-1 truncate font-mono text-[12px] font-semibold text-foreground" title={session.ip_address}>
|
|
{session.ip_address || '—'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
title="Copy IP"
|
|
onClick={() => copyIp(session.ip_address)}
|
|
className="w-6 h-6 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer flex items-center justify-center shrink-0"
|
|
>
|
|
{copiedIp === session.ip_address ? <Check className="w-3 h-3 text-success" /> : <Copy className="w-3 h-3" />}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground w-[58px] shrink-0">
|
|
<Clock className="w-3 h-3" /> Expires
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-[12px] font-semibold text-foreground" title={format(new Date(session.expires_at), 'dd MMM yyyy, hh:mm a')}>
|
|
{format(new Date(session.expires_at), 'dd MMM, hh:mm a')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{live ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRevoke(session.session_id)}
|
|
disabled={revoking === session.session_id}
|
|
className="mt-2.5 inline-flex items-center justify-center gap-1.5 h-8 min-h-8 w-full crm-radius-control border border-border bg-card text-[12px] font-semibold text-destructive hover:bg-destructive/10 cursor-pointer disabled:opacity-50 whitespace-nowrap"
|
|
>
|
|
{revoking === session.session_id ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<ShieldOff className="w-3.5 h-3.5" />
|
|
)}
|
|
Revoke
|
|
</button>
|
|
) : (
|
|
<div className="mt-2.5 h-8 min-h-8 w-full crm-radius-control border border-border bg-muted/40 text-[12px] font-medium text-muted-foreground flex items-center justify-center whitespace-nowrap">
|
|
Ended
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<TablePagination {...devicePager} />
|
|
</div>
|
|
|
|
<div ref={failedRef} className={dataCardShell}>
|
|
<div className="px-5 py-4 border-b border-border">
|
|
<h2 className="text-[16px] font-semibold text-foreground">Failed login audit</h2>
|
|
<p className="text-[12px] text-muted-foreground mt-0.5">Authentication attempts that did not succeed.</p>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="crm-data-table min-w-[800px]">
|
|
<thead>
|
|
<tr className="border-b border-border bg-gray-50">
|
|
<th className="px-5 py-3 text-gray-700">Action</th>
|
|
<th className="px-5 py-3 text-gray-700">Entity</th>
|
|
<th className="px-5 py-3 text-gray-700">IP address</th>
|
|
<th className="px-5 py-3 text-gray-700">User agent</th>
|
|
<th className="px-5 py-3 text-gray-700">Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-5 py-12 text-center">
|
|
<Loader2 className="w-6 h-6 animate-spin text-primary mx-auto" />
|
|
</td>
|
|
</tr>
|
|
) : failedLogins.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-5 py-12 text-center text-[13px] text-muted-foreground whitespace-normal">
|
|
No security failures recorded
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pager.items.map((log) => (
|
|
<tr key={log.audit_id} className="border-b border-border hover:bg-muted/10 transition-colors">
|
|
<td className="px-5 py-3.5">
|
|
<span className="inline-flex items-center px-2.5 py-1 crm-radius-badge bg-destructive text-white text-[11px] font-semibold">
|
|
{log.action}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3.5 text-muted-foreground">{log.entity_type}</td>
|
|
<td className="px-5 py-3.5 text-muted-foreground font-mono">{log.ip_address}</td>
|
|
<td className="px-5 py-3.5 text-muted-foreground">
|
|
<span className="crm-cell-clip max-w-[220px]" title={log.user_agent || ''}>
|
|
{log.user_agent || '—'}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3.5 text-muted-foreground">
|
|
{format(new Date(log.created_at), 'dd MMM yyyy, hh:mm a')}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 gap-4">
|
|
<div className={`${dataCardShell} p-4 flex flex-col`}>
|
|
<div className="flex items-center gap-2 text-destructive mb-3">
|
|
<ShieldAlert className="w-4 h-4 shrink-0" />
|
|
<h3 className="text-[14px] font-semibold">Global kill switch</h3>
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mb-4 flex-1">
|
|
Disables public client access and ends every active user session.
|
|
</p>
|
|
<form onSubmit={handleKillSwitch} className="space-y-2">
|
|
<div className="relative">
|
|
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="Admin MFA code"
|
|
value={mfaCode}
|
|
onChange={(e) => setMfaCode(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-2 w-full">
|
|
<button type="submit" disabled={actionLoading} className={`${primaryButton} w-full bg-destructive hover:bg-destructive/90`}>
|
|
{actionLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Trigger lockout'}
|
|
</button>
|
|
<button type="button" onClick={handleCancelKillSwitch} disabled={actionLoading} className={`${secondaryButton} w-full`}>
|
|
Restore
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div className={`${dataCardShell} p-4 flex flex-col`}>
|
|
<div className="flex items-center gap-2 text-destructive mb-3">
|
|
<Flame className="w-4 h-4 shrink-0" />
|
|
<h3 className="text-[14px] font-semibold">Force session termination</h3>
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mb-4 flex-1">
|
|
Ends all sessions in the database without changing the lockout setting.
|
|
</p>
|
|
<form onSubmit={handleSelfDestruct} className="space-y-2">
|
|
<div className="relative">
|
|
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="Admin MFA code"
|
|
value={mfaCode}
|
|
onChange={(e) => setMfaCode(e.target.value)}
|
|
className={inputClass}
|
|
/>
|
|
</div>
|
|
<button type="submit" disabled={actionLoading} className={`${primaryButton} w-full bg-destructive hover:bg-destructive/90`}>
|
|
{actionLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Kill all sessions'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className={`${dataCardShell} p-4 flex flex-col border-destructive/30`}>
|
|
<div className="flex items-center gap-2 text-destructive mb-3">
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
<h3 className="text-[14px] font-semibold">Platform factory reset</h3>
|
|
</div>
|
|
<p className="text-[12px] text-muted-foreground mb-4 flex-1 leading-relaxed">
|
|
Wipes core, CRM and commerce tables plus uploads, then reseeds master roles.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setResetPassword('');
|
|
setResetPhrase('');
|
|
setShowResetDialog(true);
|
|
}}
|
|
disabled={actionLoading}
|
|
className={`${primaryButton} w-full bg-destructive hover:bg-destructive/90`}
|
|
>
|
|
Execute factory reset
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`${dataCardShell} p-4 flex flex-col`}>
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<Shield className="w-4 h-4 text-primary shrink-0" />
|
|
<h3 className="text-[14px] font-semibold text-foreground">Infrastructure</h3>
|
|
</div>
|
|
<div className="space-y-2 text-[12px] flex-1">
|
|
<div className="flex justify-between py-1.5 border-b border-border">
|
|
<span className="text-muted-foreground">Admin authority</span>
|
|
<span className="font-semibold text-success">{currentUserId ? 'Super Admin' : 'Unknown'}</span>
|
|
</div>
|
|
<div className="flex justify-between py-1.5 border-b border-border">
|
|
<span className="text-muted-foreground">Audit log engine</span>
|
|
<span className="font-semibold text-foreground">Operational</span>
|
|
</div>
|
|
<div className="flex justify-between py-1.5 border-b border-border">
|
|
<span className="text-muted-foreground">MFA router</span>
|
|
<span className="font-semibold text-foreground">SHA256 Standard</span>
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={fetchData} className={`${secondaryButton} w-full mt-4`}>
|
|
Refresh diagnostics
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showResetDialog && (
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
|
|
onClick={() => !actionLoading && setShowResetDialog(false)}
|
|
>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="factory-reset-title"
|
|
className="bg-card border border-border shadow-2xl w-full max-w-sm overflow-hidden crm-radius-none"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-start justify-between gap-3 px-5 pt-5 pb-2">
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="w-10 h-10 rounded-full bg-destructive/10 text-destructive flex items-center justify-center shrink-0">
|
|
<AlertTriangle className="w-5 h-5" />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 id="factory-reset-title" className="text-[15px] font-semibold text-foreground">
|
|
Factory reset
|
|
</h3>
|
|
<p className="text-[13px] text-muted-foreground mt-1 leading-relaxed">
|
|
This permanently wipes platform data. Type the confirmation phrase to continue.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Close factory reset dialog"
|
|
onClick={() => setShowResetDialog(false)}
|
|
disabled={actionLoading}
|
|
className="w-8 h-8 rounded-full border border-border text-muted-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center shrink-0 disabled:opacity-50"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="px-5 pb-2 space-y-3">
|
|
<div>
|
|
<label className={labelClass}>Admin password</label>
|
|
<input
|
|
type="password"
|
|
value={resetPassword}
|
|
onChange={(e) => setResetPassword(e.target.value)}
|
|
className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px] text-foreground focus:outline-none focus:border-primary"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelClass}>Confirmation phrase</label>
|
|
<input
|
|
type="text"
|
|
value={resetPhrase}
|
|
onChange={(e) => setResetPhrase(e.target.value)}
|
|
placeholder="CONFIRM_FACTORY_RESET_WIPE_2026"
|
|
className="w-full h-9 px-3 crm-radius-control border border-border bg-card text-[13px] text-foreground focus:outline-none focus:border-primary font-mono"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2 px-5 pb-5 pt-3">
|
|
<button type="button" onClick={() => setShowResetDialog(false)} disabled={actionLoading} className={secondaryButton}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleFactoryReset}
|
|
disabled={actionLoading}
|
|
className="inline-flex items-center justify-center gap-2 h-9 min-h-9 px-4 crm-radius-control bg-destructive hover:bg-destructive/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50 whitespace-nowrap"
|
|
>
|
|
{actionLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : 'Execute reset'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|