2d3d450e19
- add `Logout02Icon` to admin top bar logout button - add `SmartPhone01Icon` and `ComputerIcon` to profile page session list - update icons index to use hugeicons react package imports
525 lines
25 KiB
JavaScript
525 lines
25 KiB
JavaScript
'use client';
|
|
|
|
import { registerPage } from '../registry.js';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { Card, Input, Button, TabNav, UserAvatar, Modal, PasswordStrengthIndicator } from '@zen/core/shared/components';
|
|
import { SmartPhone01Icon, ComputerIcon } from '@zen/core/shared/icons';
|
|
import { useToast } from '@zen/core/toast';
|
|
import AdminHeader from '../components/AdminHeader.js';
|
|
|
|
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 }) => {
|
|
const toast = useToast();
|
|
const fileInputRef = useRef(null);
|
|
const [activeTab, setActiveTab] = useState('informations');
|
|
const [user, setUser] = useState(initialUser);
|
|
const [loading, setLoading] = useState(false);
|
|
const [uploadingImage, setUploadingImage] = useState(false);
|
|
const [formData, setFormData] = useState({ name: initialUser?.name || '' });
|
|
|
|
const [emailModalOpen, setEmailModalOpen] = useState(false);
|
|
const [emailFormData, setEmailFormData] = useState({ newEmail: '', password: '' });
|
|
const [emailLoading, setEmailLoading] = useState(false);
|
|
const [pendingEmailMessage, setPendingEmailMessage] = useState('');
|
|
|
|
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.sessions) {
|
|
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) => {
|
|
if (!password || password.length < 8) return 'Le mot de passe doit contenir au moins 8 caractères';
|
|
if (password.length > 128) return 'Le mot de passe doit contenir 128 caractères ou moins';
|
|
if (!/[A-Z]/.test(password)) return 'Le mot de passe doit contenir au moins une majuscule';
|
|
if (!/[a-z]/.test(password)) return 'Le mot de passe doit contenir au moins une minuscule';
|
|
if (!/\d/.test(password)) return 'Le mot de passe doit contenir au moins un chiffre';
|
|
return null;
|
|
};
|
|
|
|
const handlePasswordSubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (!passwordForm.currentPassword) { toast.error('Le mot de passe actuel est requis'); return; }
|
|
const pwdError = validatePassword(passwordForm.newPassword);
|
|
if (pwdError) { toast.error(pwdError); return; }
|
|
if (passwordForm.newPassword !== passwordForm.confirmPassword) { toast.error('Les mots de passe ne correspondent pas'); return; }
|
|
setPasswordLoading(true);
|
|
try {
|
|
const response = await fetch('/zen/api/users/profile/password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword }),
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok || !data.success) throw new Error(data.error || data.message || 'Échec de la mise à jour du mot de passe');
|
|
toast.success(data.message);
|
|
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
|
} catch (error) {
|
|
toast.error(error.message || 'Échec de la mise à jour du mot de passe');
|
|
} finally {
|
|
setPasswordLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleEmailSubmit = async () => {
|
|
if (!emailFormData.newEmail.trim()) {
|
|
toast.error('Le nouveau courriel est requis');
|
|
return;
|
|
}
|
|
if (!emailFormData.password) {
|
|
toast.error('Le mot de passe est requis');
|
|
return;
|
|
}
|
|
setEmailLoading(true);
|
|
try {
|
|
const response = await fetch('/zen/api/users/profile/email', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ newEmail: emailFormData.newEmail.trim(), password: emailFormData.password }),
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok || !data.success) throw new Error(data.error || data.message || 'Échec de la demande de changement de courriel');
|
|
toast.success(data.message);
|
|
setPendingEmailMessage(data.message);
|
|
setEmailModalOpen(false);
|
|
setEmailFormData({ newEmail: '', password: '' });
|
|
} catch (error) {
|
|
toast.error(error.message || 'Échec de la demande de changement de courriel');
|
|
} finally {
|
|
setEmailLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
if (!formData.name.trim()) {
|
|
toast.error('Le nom est requis');
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
try {
|
|
const response = await fetch('/zen/api/users/profile', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ name: formData.name.trim() }),
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok || !data.success) throw new Error(data.error || 'Échec de la mise à jour du profil');
|
|
setUser(data.user);
|
|
toast.success('Profil mis à jour avec succès');
|
|
setTimeout(() => window.location.reload(), 1000);
|
|
} catch (error) {
|
|
toast.error(error.message || 'Échec de la mise à jour du profil');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleImageSelect = async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('Veuillez sélectionner un fichier image');
|
|
return;
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
toast.error("L'image doit faire moins de 5MB");
|
|
return;
|
|
}
|
|
setUploadingImage(true);
|
|
try {
|
|
const body = new FormData();
|
|
body.append('file', file);
|
|
const response = await fetch('/zen/api/users/profile/picture', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body,
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok || !data.success) throw new Error(data.message || "Échec du téléchargement de l'image");
|
|
setUser(data.user);
|
|
toast.success('Photo de profil mise à jour avec succès');
|
|
} catch (error) {
|
|
toast.error(error.message || "Échec du téléchargement de l'image");
|
|
} finally {
|
|
setUploadingImage(false);
|
|
}
|
|
};
|
|
|
|
const handleRemoveImage = async () => {
|
|
if (!user?.image) return;
|
|
setUploadingImage(true);
|
|
try {
|
|
const response = await fetch('/zen/api/users/profile/picture', {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok || !data.success) throw new Error(data.message || "Échec de la suppression de l'image");
|
|
setUser(data.user);
|
|
toast.success('Photo de profil supprimée avec succès');
|
|
} catch (error) {
|
|
toast.error(error.message || "Échec de la suppression de l'image");
|
|
} finally {
|
|
setUploadingImage(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
|
<AdminHeader title="Mon profil" description="Gérez les informations de votre compte" />
|
|
|
|
<div className="flex flex-col gap-6 items-start">
|
|
<TabNav tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
|
|
|
|
{activeTab === 'informations' && (
|
|
<Card
|
|
title="Informations personnelles"
|
|
className="w-full lg:max-w-4/5"
|
|
footer={
|
|
<div className="flex items-center justify-end gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => setFormData({ name: user?.name || '' })}
|
|
disabled={loading || !hasChanges}
|
|
>
|
|
Réinitialiser
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
disabled={loading || !hasChanges}
|
|
loading={loading}
|
|
onClick={handleSubmit}
|
|
>
|
|
Enregistrer les modifications
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Input
|
|
label="Nom complet"
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(value) => setFormData(prev => ({ ...prev, name: value }))}
|
|
placeholder="Entrez votre nom complet"
|
|
required
|
|
disabled={loading}
|
|
/>
|
|
<div className="flex flex-col gap-1">
|
|
<Input
|
|
label="Courriel"
|
|
type="email"
|
|
value={user?.email || ''}
|
|
disabled
|
|
readOnly
|
|
description={pendingEmailMessage || undefined}
|
|
/>
|
|
{!pendingEmailMessage && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEmailModalOpen(true)}
|
|
className="text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 underline self-start transition-colors"
|
|
>
|
|
Modifier le courriel
|
|
</button>
|
|
)}
|
|
</div>
|
|
<Input
|
|
label="Compte créé"
|
|
type="text"
|
|
value={user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric',
|
|
}) : 'N/D'}
|
|
disabled
|
|
readOnly
|
|
/>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{activeTab === 'securite' && (
|
|
<Card
|
|
title="Changer le mot de passe"
|
|
className="w-full lg:max-w-4/5"
|
|
footer={
|
|
<div className="flex items-center justify-end gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })}
|
|
disabled={passwordLoading}
|
|
>
|
|
Réinitialiser
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
form="password-form"
|
|
variant="primary"
|
|
disabled={passwordLoading}
|
|
loading={passwordLoading}
|
|
>
|
|
Enregistrer le mot de passe
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<form id="password-form" onSubmit={handlePasswordSubmit} className="flex flex-col gap-4">
|
|
<Input
|
|
label="Mot de passe actuel"
|
|
type="password"
|
|
value={passwordForm.currentPassword}
|
|
onChange={(value) => setPasswordForm(prev => ({ ...prev, currentPassword: value }))}
|
|
placeholder="••••••••"
|
|
autoComplete="current-password"
|
|
required
|
|
disabled={passwordLoading}
|
|
/>
|
|
<div>
|
|
<Input
|
|
label="Nouveau mot de passe"
|
|
type="password"
|
|
value={passwordForm.newPassword}
|
|
onChange={(value) => setPasswordForm(prev => ({ ...prev, newPassword: value }))}
|
|
placeholder="••••••••"
|
|
autoComplete="new-password"
|
|
minLength="8"
|
|
maxLength="128"
|
|
required
|
|
disabled={passwordLoading}
|
|
/>
|
|
<PasswordStrengthIndicator password={passwordForm.newPassword} showRequirements={true} />
|
|
</div>
|
|
<Input
|
|
label="Confirmer le nouveau mot de passe"
|
|
type="password"
|
|
value={passwordForm.confirmPassword}
|
|
onChange={(value) => setPasswordForm(prev => ({ ...prev, confirmPassword: value }))}
|
|
placeholder="••••••••"
|
|
autoComplete="new-password"
|
|
minLength="8"
|
|
maxLength="128"
|
|
required
|
|
disabled={passwordLoading}
|
|
/>
|
|
</form>
|
|
</Card>
|
|
)}
|
|
|
|
{activeTab === 'sessions' && (
|
|
<Card
|
|
title="Sessions actives"
|
|
className="w-full lg:max-w-4/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' ? (
|
|
<SmartPhone01Icon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
|
|
) : (
|
|
<ComputerIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
|
|
)}
|
|
<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="w-full lg:max-w-4/5">
|
|
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
Téléchargez une nouvelle photo de profil. Taille max 5MB.
|
|
</p>
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
|
|
<div className="relative shrink-0">
|
|
<UserAvatar user={user} size="xl" />
|
|
{uploadingImage && (
|
|
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
|
|
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={handleImageSelect}
|
|
className="hidden"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploadingImage}
|
|
>
|
|
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
|
|
</Button>
|
|
{user?.image && (
|
|
<Button
|
|
type="button"
|
|
variant="danger"
|
|
onClick={handleRemoveImage}
|
|
disabled={uploadingImage}
|
|
>
|
|
Supprimer
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Modal
|
|
isOpen={emailModalOpen}
|
|
onClose={() => { setEmailModalOpen(false); setEmailFormData({ newEmail: '', password: '' }); }}
|
|
title="Modifier le courriel"
|
|
onSubmit={handleEmailSubmit}
|
|
submitLabel="Envoyer la confirmation"
|
|
loading={emailLoading}
|
|
size="sm"
|
|
>
|
|
<div className="flex flex-col gap-4">
|
|
<Input
|
|
label="Nouveau courriel"
|
|
type="email"
|
|
value={emailFormData.newEmail}
|
|
onChange={(value) => setEmailFormData(prev => ({ ...prev, newEmail: value }))}
|
|
placeholder="nouvelle@adresse.com"
|
|
required
|
|
disabled={emailLoading}
|
|
/>
|
|
<Input
|
|
label="Mot de passe actuel"
|
|
type="password"
|
|
value={emailFormData.password}
|
|
onChange={(value) => setEmailFormData(prev => ({ ...prev, password: value }))}
|
|
placeholder="Votre mot de passe"
|
|
required
|
|
disabled={emailLoading}
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ProfilePage;
|
|
|
|
registerPage({ slug: 'profile', title: 'Mon profil', Component: ProfilePage });
|