2025-12-03 21:56:50 +00:00
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import { apiClient } from '@/services/api/client';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import {
|
|
|
|
|
Card,
|
|
|
|
|
CardContent,
|
|
|
|
|
CardDescription,
|
|
|
|
|
CardHeader,
|
|
|
|
|
CardTitle,
|
|
|
|
|
} from '@/components/ui/card';
|
|
|
|
|
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
2025-12-13 02:34:34 +00:00
|
|
|
import {
|
|
|
|
|
Shield,
|
|
|
|
|
Smartphone,
|
|
|
|
|
Monitor,
|
|
|
|
|
Globe,
|
|
|
|
|
Trash2,
|
|
|
|
|
LogOut,
|
|
|
|
|
} from 'lucide-react';
|
2025-12-03 21:56:50 +00:00
|
|
|
|
|
|
|
|
interface Session {
|
|
|
|
|
id: string;
|
|
|
|
|
ip_address: string;
|
|
|
|
|
user_agent: string;
|
|
|
|
|
last_activity: string;
|
|
|
|
|
created_at: string;
|
|
|
|
|
is_current: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface SessionsResponse {
|
|
|
|
|
sessions: Session[];
|
|
|
|
|
count: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function SessionsPage() {
|
|
|
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [revoking, setRevoking] = useState<string | null>(null);
|
|
|
|
|
const [revokingAll, setRevokingAll] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchSessions();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const fetchSessions = async () => {
|
|
|
|
|
try {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
const response = await apiClient.get<SessionsResponse>('/auth/sessions');
|
|
|
|
|
setSessions(response.data.sessions);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error('Failed to fetch sessions', error);
|
|
|
|
|
setError(
|
2025-12-13 02:34:34 +00:00
|
|
|
error.response?.data?.error ||
|
|
|
|
|
'Failed to load sessions. Please try again.',
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const revokeSession = async (sessionId: string) => {
|
|
|
|
|
try {
|
|
|
|
|
setRevoking(sessionId);
|
|
|
|
|
setError(null);
|
|
|
|
|
await apiClient.delete(`/auth/sessions/${sessionId}`);
|
|
|
|
|
await fetchSessions();
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error('Failed to revoke session', error);
|
|
|
|
|
setError(
|
2025-12-13 02:34:34 +00:00
|
|
|
error.response?.data?.error ||
|
|
|
|
|
'Failed to revoke session. Please try again.',
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setRevoking(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const revokeAllOther = async () => {
|
|
|
|
|
if (
|
|
|
|
|
!window.confirm(
|
2025-12-13 02:34:34 +00:00
|
|
|
'Are you sure you want to revoke all other sessions? You will remain logged in on this device.',
|
2025-12-03 21:56:50 +00:00
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
setRevokingAll(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
await apiClient.delete('/auth/sessions');
|
|
|
|
|
await fetchSessions();
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
console.error('Failed to revoke sessions', error);
|
|
|
|
|
setError(
|
2025-12-13 02:34:34 +00:00
|
|
|
error.response?.data?.error ||
|
|
|
|
|
'Failed to revoke sessions. Please try again.',
|
2025-12-03 21:56:50 +00:00
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
setRevokingAll(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getDeviceIcon = (userAgent: string) => {
|
|
|
|
|
const ua = userAgent.toLowerCase();
|
2025-12-13 02:34:34 +00:00
|
|
|
if (
|
|
|
|
|
ua.includes('mobile') ||
|
|
|
|
|
ua.includes('android') ||
|
|
|
|
|
ua.includes('iphone')
|
|
|
|
|
) {
|
2025-12-03 21:56:50 +00:00
|
|
|
return <Smartphone className="h-5 w-5" />;
|
|
|
|
|
}
|
|
|
|
|
if (ua.includes('tablet') || ua.includes('ipad')) {
|
|
|
|
|
return <Monitor className="h-5 w-5" />;
|
|
|
|
|
}
|
|
|
|
|
return <Monitor className="h-5 w-5" />;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateString: string) => {
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
return new Intl.DateTimeFormat('en-US', {
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
}).format(date);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-center min-h-[400px]">
|
|
|
|
|
<LoadingSpinner />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* En-tête */}
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-3xl font-bold tracking-tight">Active Sessions</h1>
|
|
|
|
|
<p className="text-muted-foreground">
|
|
|
|
|
Manage your active sessions and sign out from other devices
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Message d'erreur */}
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="bg-destructive/10 text-destructive px-4 py-3 rounded-md">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Bouton pour révoquer toutes les autres sessions */}
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
onClick={revokeAllOther}
|
|
|
|
|
disabled={revokingAll || sessions.length <= 1}
|
|
|
|
|
variant="destructive"
|
|
|
|
|
>
|
|
|
|
|
{revokingAll ? (
|
|
|
|
|
<>
|
|
|
|
|
<LoadingSpinner className="mr-2 h-4 w-4" />
|
|
|
|
|
Revoking...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
|
|
|
Revoke All Other Sessions
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Liste des sessions */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader>
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
<Shield className="h-5 w-5" />
|
|
|
|
|
Sessions ({sessions.length})
|
|
|
|
|
</CardTitle>
|
|
|
|
|
<CardDescription>
|
|
|
|
|
These are the devices where you're currently signed in
|
|
|
|
|
</CardDescription>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{sessions.length === 0 ? (
|
|
|
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
|
|
|
No active sessions found.
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-4">
|
2025-12-13 02:34:34 +00:00
|
|
|
{sessions.map((session) => (
|
2025-12-03 21:56:50 +00:00
|
|
|
<div
|
|
|
|
|
key={session.id}
|
|
|
|
|
className={`flex items-center justify-between p-4 border rounded-lg ${
|
|
|
|
|
session.is_current
|
|
|
|
|
? 'border-primary bg-primary/5'
|
|
|
|
|
: 'border-border'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start gap-4 flex-1">
|
|
|
|
|
<div className="mt-1">
|
|
|
|
|
{getDeviceIcon(session.user_agent)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 space-y-1">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{session.user_agent || 'Unknown Device'}
|
|
|
|
|
</p>
|
|
|
|
|
{session.is_current && (
|
|
|
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-primary text-primary-foreground rounded">
|
|
|
|
|
Current Session
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<Globe className="h-4 w-4" />
|
|
|
|
|
<span>{session.ip_address || 'Unknown IP'}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="font-medium">Created:</span>{' '}
|
|
|
|
|
{formatDate(session.created_at)}
|
|
|
|
|
</div>
|
|
|
|
|
{session.last_activity && (
|
|
|
|
|
<div>
|
|
|
|
|
<span className="font-medium">Last activity:</span>{' '}
|
|
|
|
|
{formatDate(session.last_activity)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{!session.is_current && (
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => revokeSession(session.id)}
|
|
|
|
|
disabled={revoking === session.id}
|
|
|
|
|
variant="destructive"
|
|
|
|
|
size="sm"
|
|
|
|
|
>
|
|
|
|
|
{revoking === session.id ? (
|
|
|
|
|
<>
|
|
|
|
|
<LoadingSpinner className="mr-2 h-4 w-4" />
|
|
|
|
|
Revoking...
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
|
|
|
Revoke
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default SessionsPage;
|