feat(admin): add session management tab to profile page

- add sessions tab with active session list in ProfilePage
- fetch and display sessions with current session highlight
- implement single and bulk session revocation with redirect on self-revoke
- add session-related api helpers in auth api
This commit is contained in:
2026-04-24 16:59:54 -04:00
parent 221836d91c
commit a92b4334f1
2 changed files with 200 additions and 1 deletions
@@ -10,6 +10,7 @@ const TABS = [
{ id: 'informations', label: 'Informations' },
{ id: 'photo', label: 'Photo de profil' },
{ id: 'securite', label: 'Sécurité' },
{ id: 'sessions', label: 'Sessions' },
];
const ProfilePage = ({ user: initialUser }) => {
@@ -29,10 +30,62 @@ const ProfilePage = ({ user: initialUser }) => {
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [passwordLoading, setPasswordLoading] = useState(false);
const [sessions, setSessions] = useState([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState(null);
useEffect(() => {
if (initialUser) setFormData({ name: initialUser.name || '' });
}, [initialUser]);
useEffect(() => {
if (activeTab !== 'sessions') return;
setSessionsLoading(true);
fetch('/zen/api/users/profile/sessions', { credentials: 'include' })
.then(r => r.json())
.then(data => {
if (data.success) {
setSessions(data.sessions);
setCurrentSessionId(data.currentSessionId);
}
})
.catch(() => toast.error('Impossible de charger les sessions'))
.finally(() => setSessionsLoading(false));
}, [activeTab]);
const handleRevokeSession = async (sessionId) => {
try {
const response = await fetch(`/zen/api/users/profile/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'Impossible de révoquer la session');
if (data.isCurrent) {
window.location.href = '/admin/login';
} else {
setSessions(prev => prev.filter(s => s.id !== sessionId));
}
} catch (error) {
toast.error(error.message || 'Impossible de révoquer la session');
}
};
const handleRevokeAllSessions = async () => {
if (!confirm('Révoquer toutes les sessions ? Vous serez déconnecté de tous les appareils.')) return;
try {
const response = await fetch('/zen/api/users/profile/sessions', {
method: 'DELETE',
credentials: 'include',
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'Impossible de révoquer les sessions');
window.location.href = '/admin/login';
} catch (error) {
toast.error(error.message || 'Impossible de révoquer les sessions');
}
};
const hasChanges = formData.name !== user?.name;
const validatePassword = (password) => {
@@ -321,6 +374,74 @@ const ProfilePage = ({ user: initialUser }) => {
</Card>
)}
{activeTab === 'sessions' && (
<Card
title="Sessions actives"
className="min-w-3/5"
footer={
<div className="flex justify-end">
<Button
type="button"
variant="danger"
onClick={handleRevokeAllSessions}
disabled={sessionsLoading || sessions.length === 0}
>
Révoquer toutes les sessions
</Button>
</div>
}
>
{sessionsLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-neutral-900 dark:border-t-neutral-100 rounded-full animate-spin" />
</div>
) : sessions.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">Aucune session active</p>
) : (
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-800">
{sessions.map(session => (
<div key={session.id} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
{session.device === 'mobile' ? (
<svg className="w-8 h-8 text-neutral-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 8.25h3" />
</svg>
) : (
<svg className="w-8 h-8 text-neutral-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0H3" />
</svg>
)}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{session.browser} · {session.os}
</span>
{session.id === currentSessionId && (
<span className="text-xs bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded-full font-medium">
Session actuelle
</span>
)}
</div>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{session.ip_address || 'IP inconnue'} · {new Date(session.created_at).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
</span>
</div>
</div>
<Button
type="button"
variant="danger"
size="sm"
onClick={() => handleRevokeSession(session.id)}
>
Révoquer
</Button>
</div>
))}
</div>
)}
</Card>
)}
{activeTab === 'photo' && (
<Card title="Photo de profil" className="min-w-3/5">
<p className="text-sm text-neutral-500 dark:text-neutral-400">