diff --git a/src/features/admin/pages/ProfilePage.client.js b/src/features/admin/pages/ProfilePage.client.js index 724b7ea..f44f606 100644 --- a/src/features/admin/pages/ProfilePage.client.js +++ b/src/features/admin/pages/ProfilePage.client.js @@ -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 }) => { )} + {activeTab === 'sessions' && ( + + + + } + > + {sessionsLoading ? ( +
+
+
+ ) : sessions.length === 0 ? ( +

Aucune session active

+ ) : ( +
+ {sessions.map(session => ( +
+
+ {session.device === 'mobile' ? ( + + + + ) : ( + + + + )} +
+
+ + {session.browser} · {session.os} + + {session.id === currentSessionId && ( + + Session actuelle + + )} +
+ + {session.ip_address || 'IP inconnue'} · {new Date(session.created_at).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })} + +
+
+ +
+ ))} +
+ )} + + )} + {activeTab === 'photo' && (

diff --git a/src/features/auth/api.js b/src/features/auth/api.js index f87fe4c..d57fb7b 100644 --- a/src/features/auth/api.js +++ b/src/features/auth/api.js @@ -11,7 +11,7 @@ import { query, updateById, findOne } from '@zen/core/database'; import { updateUser, requestPasswordReset } from './auth.js'; import { hashPassword, verifyPassword } from '../../core/users/password.js'; import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail } from './email.js'; -import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from '@zen/core/users'; +import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions } from '@zen/core/users'; import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage'; import { getPublicBaseUrl } from '@zen/core/shared/config'; @@ -627,6 +627,81 @@ async function handleDeleteRole(_request, { id: roleId }) { } } +// --------------------------------------------------------------------------- +// GET /zen/api/users/profile/sessions (user — list own sessions) +// --------------------------------------------------------------------------- + +function parseUserAgent(ua) { + if (!ua) return { browser: 'Navigateur inconnu', os: 'Système inconnu', device: 'desktop' }; + const device = /Mobile|Android|iPhone|iPad/i.test(ua) ? 'mobile' : 'desktop'; + let os = 'Système inconnu'; + if (/Windows/i.test(ua)) os = 'Windows'; + else if (/Android/i.test(ua)) os = 'Android'; + else if (/iPhone|iPad/i.test(ua)) os = 'iOS'; + else if (/Mac OS X/i.test(ua)) os = 'macOS'; + else if (/Linux/i.test(ua)) os = 'Linux'; + let browser = 'Navigateur inconnu'; + if (/Edg\//i.test(ua)) browser = 'Edge'; + else if (/Chrome\//i.test(ua)) browser = 'Chrome'; + else if (/Firefox\//i.test(ua)) browser = 'Firefox'; + else if (/Safari\//i.test(ua)) browser = 'Safari'; + return { browser, os, device }; +} + +async function handleListSessions(_request, _params, { session }) { + try { + const result = await query( + 'SELECT id, ip_address, user_agent, created_at, expires_at FROM zen_auth_sessions WHERE user_id = $1 ORDER BY created_at DESC', + [session.user.id] + ); + const sessions = result.rows.map(s => ({ + id: s.id, + ip_address: s.ip_address, + created_at: s.created_at, + expires_at: s.expires_at, + ...parseUserAgent(s.user_agent), + })); + return apiSuccess({ sessions, currentSessionId: session.session.id }); + } catch (error) { + logAndObscureError(error, null); + return apiError('Internal Server Error', 'Impossible de récupérer les sessions'); + } +} + +// --------------------------------------------------------------------------- +// DELETE /zen/api/users/profile/sessions (user — revoke all own sessions) +// --------------------------------------------------------------------------- + +async function handleDeleteAllSessions(_request, _params, { session }) { + try { + await deleteUserSessions(session.user.id); + return apiSuccess({ success: true }); + } catch (error) { + logAndObscureError(error, null); + return apiError('Internal Server Error', 'Impossible de révoquer les sessions'); + } +} + +// --------------------------------------------------------------------------- +// DELETE /zen/api/users/profile/sessions/:sessionId (user — revoke one session) +// --------------------------------------------------------------------------- + +async function handleDeleteSession(_request, { sessionId }, { session }) { + try { + const result = await query( + 'DELETE FROM zen_auth_sessions WHERE id = $1 AND user_id = $2 RETURNING id', + [sessionId, session.user.id] + ); + if (result.rows.length === 0) { + return apiError('Not Found', 'Session introuvable'); + } + return apiSuccess({ success: true, isCurrent: sessionId === session.session.id }); + } catch (error) { + logAndObscureError(error, null); + return apiError('Internal Server Error', 'Impossible de révoquer la session'); + } +} + // --------------------------------------------------------------------------- // POST /zen/api/users/profile/password (user — change own password) // --------------------------------------------------------------------------- @@ -746,6 +821,9 @@ export const routes = defineApiRoutes([ { path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' }, { path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' }, { path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' }, + { path: '/users/profile/sessions', method: 'GET', handler: handleListSessions, auth: 'user' }, + { path: '/users/profile/sessions', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' }, + { path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, auth: 'user' }, { path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' }, { path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' }, { path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },