project-standalo-sonic-cloud/app/components/SessionManager.tsx

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>
);
}