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:
@@ -10,6 +10,7 @@ const TABS = [
|
|||||||
{ id: 'informations', label: 'Informations' },
|
{ id: 'informations', label: 'Informations' },
|
||||||
{ id: 'photo', label: 'Photo de profil' },
|
{ id: 'photo', label: 'Photo de profil' },
|
||||||
{ id: 'securite', label: 'Sécurité' },
|
{ id: 'securite', label: 'Sécurité' },
|
||||||
|
{ id: 'sessions', label: 'Sessions' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ProfilePage = ({ user: initialUser }) => {
|
const ProfilePage = ({ user: initialUser }) => {
|
||||||
@@ -29,10 +30,62 @@ const ProfilePage = ({ user: initialUser }) => {
|
|||||||
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||||
|
|
||||||
|
const [sessions, setSessions] = useState([]);
|
||||||
|
const [sessionsLoading, setSessionsLoading] = useState(false);
|
||||||
|
const [currentSessionId, setCurrentSessionId] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialUser) setFormData({ name: initialUser.name || '' });
|
if (initialUser) setFormData({ name: initialUser.name || '' });
|
||||||
}, [initialUser]);
|
}, [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 hasChanges = formData.name !== user?.name;
|
||||||
|
|
||||||
const validatePassword = (password) => {
|
const validatePassword = (password) => {
|
||||||
@@ -321,6 +374,74 @@ const ProfilePage = ({ user: initialUser }) => {
|
|||||||
</Card>
|
</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' && (
|
{activeTab === 'photo' && (
|
||||||
<Card title="Photo de profil" className="min-w-3/5">
|
<Card title="Photo de profil" className="min-w-3/5">
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { query, updateById, findOne } from '@zen/core/database';
|
|||||||
import { updateUser, requestPasswordReset } from './auth.js';
|
import { updateUser, requestPasswordReset } from './auth.js';
|
||||||
import { hashPassword, verifyPassword } from '../../core/users/password.js';
|
import { hashPassword, verifyPassword } from '../../core/users/password.js';
|
||||||
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail } from './email.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 { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
||||||
import { getPublicBaseUrl } from '@zen/core/shared/config';
|
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)
|
// 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/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' },
|
||||||
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, 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/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/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' },
|
||||||
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
|
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
|
||||||
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
|
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
|
||||||
|
|||||||
Reference in New Issue
Block a user