294 lines
9.9 KiB
TypeScript
294 lines
9.9 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import type { Session } from '../types/api';
|
|
import { API_PATHS } from '../types/api';
|
|
|
|
interface SessionManagerProps {
|
|
onSessionRevoke?: (sessionId: string) => void;
|
|
onRefreshSessions?: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
export default function SessionManager({
|
|
onSessionRevoke,
|
|
onRefreshSessions,
|
|
className = '',
|
|
}: SessionManagerProps) {
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
|
|
|
// Fetch sessions
|
|
const fetchSessions = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
|
|
const response = await fetch(API_PATHS.AUTH_SESSIONS, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch sessions');
|
|
}
|
|
|
|
const data = await response.json();
|
|
setSessions(data.sessions || []);
|
|
setCurrentSessionId(data.currentSessionId);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Revoke session
|
|
const revokeSession = async (sessionId: string) => {
|
|
try {
|
|
const revokePath = API_PATHS.AUTH_REVOKE_SESSION.replace(':id', sessionId);
|
|
|
|
const response = await fetch(revokePath, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Failed to revoke session');
|
|
}
|
|
|
|
// Remove session from list
|
|
setSessions(prev => prev.filter(s => s.id !== sessionId));
|
|
onSessionRevoke?.(sessionId);
|
|
|
|
// If we revoked the current session, we should log out
|
|
if (sessionId === currentSessionId) {
|
|
// Trigger logout
|
|
window.location.href = '/login?reason=session_revoked';
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to revoke session');
|
|
}
|
|
};
|
|
|
|
// Format device info
|
|
const formatDeviceInfo = (deviceInfo?: Record<string, unknown>): string => {
|
|
if (!deviceInfo) return 'Unknown Device';
|
|
|
|
const browser = deviceInfo.browser as string;
|
|
const os = deviceInfo.os as string;
|
|
const platform = deviceInfo.platform as string;
|
|
|
|
if (browser && os) {
|
|
return `${browser} on ${os}`;
|
|
} else if (platform) {
|
|
return platform;
|
|
}
|
|
|
|
return 'Unknown Device';
|
|
};
|
|
|
|
// Format last activity
|
|
const formatLastActivity = (lastActivity?: Date): string => {
|
|
if (!lastActivity) return 'Never';
|
|
|
|
const now = new Date();
|
|
const diff = now.getTime() - new Date(lastActivity).getTime();
|
|
const minutes = Math.floor(diff / (1000 * 60));
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
|
|
if (days > 0) {
|
|
return `${days} day${days > 1 ? 's' : ''} ago`;
|
|
} else if (hours > 0) {
|
|
return `${hours} hour${hours > 1 ? 's' : ''} ago`;
|
|
} else if (minutes > 0) {
|
|
return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
|
|
} else {
|
|
return 'Just now';
|
|
}
|
|
};
|
|
|
|
// Get device icon
|
|
const getDeviceIcon = (userAgent?: string): React.ReactNode => {
|
|
const ua = userAgent?.toLowerCase() || '';
|
|
|
|
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
|
return (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M15.5 1h-8A2.5 2.5 0 005 3.5v17A2.5 2.5 0 007.5 23h8a2.5 2.5 0 002.5-2.5v-17A2.5 2.5 0 0015.5 1zm-4 21c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4.5-4H7V4h9v14z"/>
|
|
</svg>
|
|
);
|
|
} else if (ua.includes('tablet') || ua.includes('ipad')) {
|
|
return (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M19 1H5c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V3c0-1.1-.9-2-2-2zm-5 20H10v-1h4v1zm3-3H7V4h10v14z"/>
|
|
</svg>
|
|
);
|
|
} else {
|
|
return (
|
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M21 2H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7l-2 3v1h8v-1l-2-3h7c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 12H3V4h18v10z"/>
|
|
</svg>
|
|
);
|
|
}
|
|
};
|
|
|
|
// Get location from IP (placeholder)
|
|
const getLocation = (ipAddress?: string): string => {
|
|
if (!ipAddress) return 'Unknown Location';
|
|
return ipAddress; // In production, you'd use a geolocation service
|
|
};
|
|
|
|
// Refresh sessions
|
|
const handleRefresh = () => {
|
|
fetchSessions();
|
|
onRefreshSessions?.();
|
|
};
|
|
|
|
// Load sessions on mount
|
|
useEffect(() => {
|
|
fetchSessions();
|
|
}, []);
|
|
|
|
return (
|
|
<div className={`bg-white rounded-lg shadow-lg ${className}`}>
|
|
<div className="p-6">
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-xl font-semibold text-gray-900">Active Sessions</h2>
|
|
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={isLoading}
|
|
className="px-4 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded-md transition-colors disabled:opacity-50"
|
|
>
|
|
{isLoading ? 'Refreshing...' : 'Refresh'}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Error state */}
|
|
{error && (
|
|
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
<p className="text-red-600 text-sm">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading state */}
|
|
{isLoading && sessions.length === 0 && (
|
|
<div className="flex justify-center py-8">
|
|
<svg className="animate-spin h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24">
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
)}
|
|
|
|
{/* Sessions list */}
|
|
{!isLoading && sessions.length === 0 && !error && (
|
|
<div className="text-center py-8">
|
|
<svg className="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No active sessions</h3>
|
|
<p className="text-gray-500">Sign in to see your active sessions here</p>
|
|
</div>
|
|
)}
|
|
|
|
{sessions.length > 0 && (
|
|
<div className="space-y-4">
|
|
{sessions.map(session => (
|
|
<div
|
|
key={session.id}
|
|
className={`p-4 border rounded-lg ${
|
|
session.id === currentSessionId
|
|
? 'border-blue-500 bg-blue-50'
|
|
: 'border-gray-200 bg-white'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-start gap-3 flex-1">
|
|
{/* Device icon */}
|
|
<div className={`p-2 rounded-lg ${
|
|
session.id === currentSessionId
|
|
? 'bg-blue-100 text-blue-600'
|
|
: 'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{getDeviceIcon(session.userAgent)}
|
|
</div>
|
|
|
|
{/* Session info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<h3 className="font-medium text-gray-900 truncate">
|
|
{formatDeviceInfo(session.deviceInfo)}
|
|
</h3>
|
|
|
|
{/* Current session badge */}
|
|
{session.id === currentSessionId && (
|
|
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded-full">
|
|
Current session
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1 text-sm text-gray-500">
|
|
{session.ipAddress && (
|
|
<p>IP: {getLocation(session.ipAddress)}</p>
|
|
)}
|
|
|
|
<p>
|
|
Last active: {formatLastActivity(session.lastActivity)}
|
|
</p>
|
|
|
|
<p>
|
|
Signed in: {new Date(session.createdAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Revoke button */}
|
|
{session.id !== currentSessionId && (
|
|
<button
|
|
onClick={() => revokeSession(session.id)}
|
|
className="ml-4 px-3 py-1 text-sm text-red-600 hover:text-red-700 hover:bg-red-50 rounded-md transition-colors"
|
|
>
|
|
Revoke
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Info text */}
|
|
{sessions.length > 0 && (
|
|
<div className="mt-6 p-4 bg-gray-50 rounded-lg">
|
|
<h4 className="font-medium text-gray-900 mb-2">About sessions</h4>
|
|
<p className="text-sm text-gray-600">
|
|
These are the devices where your account is currently signed in. Revoking a session will sign you out of that device.
|
|
Be careful not to revoke your current session unless you want to sign out.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |