Files
core/src/features/admin/pages/ProfilePage.client.js
T
hykocx 2d3d450e19 refactor(admin): replace inline svgs with icon components
- 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
2026-04-24 20:52:51 -04:00

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 });