feat(admin): add email change flow with confirmation for users
- add `ConfirmEmailChangePage.client.js` for email change token confirmation - add `emailChange.js` core utility to generate and verify email change tokens - add `EmailChangeConfirmEmail.js` and `EmailChangeNotifyEmail.js` email templates - update `UserEditModal` to handle email changes with password verification for self-edits - update `ProfilePage` to support email change initiation - update `UsersPage` to pass `currentUserId` to `UserEditModal` - add email change API endpoints in `auth/api.js` and `auth/email.js` - register `ConfirmEmailChangePage` in `AdminPage.client.js`
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
import { query, create, deleteWhere, updateById } from '@zen/core/database';
|
||||||
|
import { generateToken, generateId } from './password.js';
|
||||||
|
|
||||||
|
export async function createEmailChangeToken(userId, newEmail) {
|
||||||
|
const token = generateToken(32);
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||||
|
|
||||||
|
await deleteWhere('zen_auth_verifications', { identifier: 'email_change:' + userId });
|
||||||
|
|
||||||
|
await create('zen_auth_verifications', {
|
||||||
|
id: generateId(),
|
||||||
|
identifier: 'email_change:' + userId,
|
||||||
|
value: newEmail,
|
||||||
|
token,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyEmailChangeToken(token) {
|
||||||
|
const result = await query(
|
||||||
|
"SELECT * FROM zen_auth_verifications WHERE identifier LIKE 'email_change:%' AND token = $1",
|
||||||
|
[token]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) return null;
|
||||||
|
|
||||||
|
const record = result.rows[0];
|
||||||
|
|
||||||
|
// Timing-safe comparison — same-length buffer padding prevents length-based timing leaks.
|
||||||
|
const storedBuf = Buffer.from(record.token, 'utf8');
|
||||||
|
const providedBuf = Buffer.from(
|
||||||
|
token.length === record.token.length ? token : record.token,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf) && token.length === record.token.length;
|
||||||
|
if (!tokensMatch) return null;
|
||||||
|
|
||||||
|
if (new Date(record.expires_at) < new Date()) {
|
||||||
|
await deleteWhere('zen_auth_verifications', { id: record.id });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = record.identifier.slice('email_change:'.length);
|
||||||
|
const newEmail = record.value;
|
||||||
|
|
||||||
|
await deleteWhere('zen_auth_verifications', { id: record.id });
|
||||||
|
|
||||||
|
return { userId, newEmail };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyEmailChange(userId, newEmail) {
|
||||||
|
await updateById('zen_auth_users', userId, { email: newEmail, updated_at: new Date() });
|
||||||
|
await query(
|
||||||
|
'UPDATE zen_auth_accounts SET account_id = $1, updated_at = $2 WHERE user_id = $3 AND provider_id = $4',
|
||||||
|
[newEmail, new Date(), userId, 'credential']
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import './pages/UsersPage.client.js';
|
|||||||
import './pages/RolesPage.client.js';
|
import './pages/RolesPage.client.js';
|
||||||
import './pages/ProfilePage.client.js';
|
import './pages/ProfilePage.client.js';
|
||||||
import './pages/SettingsPage.client.js';
|
import './pages/SettingsPage.client.js';
|
||||||
|
import './pages/ConfirmEmailChangePage.client.js';
|
||||||
import './widgets/index.client.js';
|
import './widgets/index.client.js';
|
||||||
|
|
||||||
export default function AdminPageClient({ params, user, widgetData, appConfig }) {
|
export default function AdminPageClient({ params, user, widgetData, appConfig }) {
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Input, Select, TagInput, Modal } from '@zen/core/shared/components';
|
import { Input, Select, TagInput, Modal } from '@zen/core/shared/components';
|
||||||
import { useToast } from '@zen/core/toast';
|
import { useToast } from '@zen/core/toast';
|
||||||
|
|
||||||
const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
|
const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const isSelf = userId && currentUserId && userId === currentUserId;
|
||||||
|
|
||||||
const [userData, setUserData] = useState(null);
|
const [userData, setUserData] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({ name: '', email_verified: 'false' });
|
const [formData, setFormData] = useState({ name: '', email: '', email_verified: 'false', currentPassword: '' });
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
const [allRoles, setAllRoles] = useState([]);
|
const [allRoles, setAllRoles] = useState([]);
|
||||||
@@ -47,7 +48,9 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
|
|||||||
setUserData(userJson.user);
|
setUserData(userJson.user);
|
||||||
setFormData({
|
setFormData({
|
||||||
name: userJson.user.name || '',
|
name: userJson.user.name || '',
|
||||||
|
email: userJson.user.email || '',
|
||||||
email_verified: userJson.user.email_verified ? 'true' : 'false',
|
email_verified: userJson.user.email_verified ? 'true' : 'false',
|
||||||
|
currentPassword: '',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(userJson.message || 'Utilisateur introuvable');
|
toast.error(userJson.message || 'Utilisateur introuvable');
|
||||||
@@ -73,9 +76,12 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
|
|||||||
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
|
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const emailChanged = userData && formData.email !== userData.email;
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const newErrors = {};
|
const newErrors = {};
|
||||||
if (!formData.name?.trim()) newErrors.name = 'Le nom est requis';
|
if (!formData.name?.trim()) newErrors.name = 'Le nom est requis';
|
||||||
|
if (emailChanged && isSelf && !formData.currentPassword) newErrors.currentPassword = 'Le mot de passe est requis pour changer le courriel';
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
};
|
};
|
||||||
@@ -120,7 +126,43 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
toast.success('Utilisateur mis à jour');
|
if (emailChanged) {
|
||||||
|
if (isSelf) {
|
||||||
|
const emailRes = await fetch('/zen/api/users/profile/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ newEmail: formData.email.trim(), password: formData.currentPassword }),
|
||||||
|
});
|
||||||
|
const emailData = await emailRes.json();
|
||||||
|
if (!emailRes.ok) {
|
||||||
|
toast.error(emailData.error || emailData.message || 'Impossible de changer le courriel');
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Utilisateur mis à jour');
|
||||||
|
toast.info(emailData.message || 'Un courriel de confirmation a été envoyé');
|
||||||
|
} else {
|
||||||
|
const emailRes = await fetch(`/zen/api/users/${userId}/email`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ newEmail: formData.email.trim() }),
|
||||||
|
});
|
||||||
|
const emailData = await emailRes.json();
|
||||||
|
if (!emailRes.ok) {
|
||||||
|
toast.error(emailData.error || emailData.message || 'Impossible de changer le courriel');
|
||||||
|
onSaved?.();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Utilisateur mis à jour');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.success('Utilisateur mis à jour');
|
||||||
|
}
|
||||||
|
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -165,11 +207,24 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
|
|||||||
error={errors.name}
|
error={errors.name}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Email"
|
label="Courriel"
|
||||||
value={userData?.email || ''}
|
type="email"
|
||||||
disabled
|
value={formData.email}
|
||||||
|
onChange={(value) => handleInputChange('email', value)}
|
||||||
|
placeholder="courriel@exemple.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isSelf && emailChanged && (
|
||||||
|
<Input
|
||||||
|
label="Mot de passe actuel *"
|
||||||
|
type="password"
|
||||||
|
value={formData.currentPassword}
|
||||||
|
onChange={(value) => handleInputChange('currentPassword', value)}
|
||||||
|
placeholder="Votre mot de passe"
|
||||||
|
error={errors.currentPassword}
|
||||||
|
description="Requis pour confirmer le changement de courriel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
label="Email vérifié"
|
label="Email vérifié"
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { registerPage } from '../registry.js';
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { Card } from '@zen/core/shared/components';
|
||||||
|
|
||||||
|
const ConfirmEmailChangePage = () => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get('token');
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [success, setSuccess] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const hasConfirmedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setError('Lien de confirmation invalide.');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hasConfirmedRef.current) return;
|
||||||
|
hasConfirmedRef.current = true;
|
||||||
|
|
||||||
|
fetch(`/zen/api/users/email/confirm?token=${encodeURIComponent(token)}`, { credentials: 'include' })
|
||||||
|
.then(res => res.json().then(data => ({ ok: res.ok, data })))
|
||||||
|
.then(({ ok, data }) => {
|
||||||
|
if (ok && data.success) {
|
||||||
|
setSuccess('Votre adresse courriel a été mise à jour avec succès.');
|
||||||
|
setTimeout(() => { window.location.href = '/admin/profile'; }, 3000);
|
||||||
|
} else {
|
||||||
|
setError(data.error || data.message || 'Lien de confirmation invalide ou expiré.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setError('Une erreur inattendue est survenue.'))
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-64">
|
||||||
|
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
|
||||||
|
Confirmation du courriel
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
Validation de votre nouvelle adresse courriel...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex flex-col items-center py-10">
|
||||||
|
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white" />
|
||||||
|
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Confirmation en cours...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<>
|
||||||
|
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />
|
||||||
|
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm">
|
||||||
|
Redirection vers votre profil...
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
|
||||||
|
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmEmailChangePage;
|
||||||
|
|
||||||
|
registerPage({ slug: 'confirm-email-change', title: 'Confirmation du courriel', Component: ConfirmEmailChangePage });
|
||||||
@@ -20,12 +20,48 @@ const ProfilePage = ({ user: initialUser }) => {
|
|||||||
const [uploadingImage, setUploadingImage] = useState(false);
|
const [uploadingImage, setUploadingImage] = useState(false);
|
||||||
const [formData, setFormData] = useState({ name: initialUser?.name || '' });
|
const [formData, setFormData] = useState({ name: initialUser?.name || '' });
|
||||||
|
|
||||||
|
const [emailFormOpen, setEmailFormOpen] = useState(false);
|
||||||
|
const [emailFormData, setEmailFormData] = useState({ newEmail: '', password: '' });
|
||||||
|
const [emailLoading, setEmailLoading] = useState(false);
|
||||||
|
const [pendingEmailMessage, setPendingEmailMessage] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialUser) setFormData({ name: initialUser.name || '' });
|
if (initialUser) setFormData({ name: initialUser.name || '' });
|
||||||
}, [initialUser]);
|
}, [initialUser]);
|
||||||
|
|
||||||
const hasChanges = formData.name !== user?.name;
|
const hasChanges = formData.name !== user?.name;
|
||||||
|
|
||||||
|
const handleEmailSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
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);
|
||||||
|
setEmailFormOpen(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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
@@ -145,14 +181,56 @@ const ProfilePage = ({ user: initialUser }) => {
|
|||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Input
|
<div className="flex flex-col gap-1">
|
||||||
label="Courriel"
|
<Input
|
||||||
type="email"
|
label="Courriel"
|
||||||
value={user?.email || ''}
|
type="email"
|
||||||
disabled
|
value={user?.email || ''}
|
||||||
readOnly
|
disabled
|
||||||
description="L'email ne peut pas être modifié"
|
readOnly
|
||||||
/>
|
/>
|
||||||
|
{pendingEmailMessage ? (
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-400">{pendingEmailMessage}</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEmailFormOpen(prev => !prev); setPendingEmailMessage(''); setEmailFormData({ newEmail: '', password: '' }); }}
|
||||||
|
className="text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 underline self-start transition-colors"
|
||||||
|
>
|
||||||
|
{emailFormOpen ? 'Annuler' : 'Modifier le courriel'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{emailFormOpen && (
|
||||||
|
<form onSubmit={handleEmailSubmit} className="flex flex-col gap-3 mt-2 p-3 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50">
|
||||||
|
<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 className="flex gap-2 justify-end">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => { setEmailFormOpen(false); setEmailFormData({ newEmail: '', password: '' }); }} disabled={emailLoading}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" variant="primary" loading={emailLoading} disabled={emailLoading}>
|
||||||
|
Envoyer la confirmation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label="Compte créé"
|
label="Compte créé"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useToast } from '@zen/core/toast';
|
|||||||
import AdminHeader from '../components/AdminHeader.js';
|
import AdminHeader from '../components/AdminHeader.js';
|
||||||
import UserEditModal from '../components/UserEditModal.client.js';
|
import UserEditModal from '../components/UserEditModal.client.js';
|
||||||
|
|
||||||
const UsersPageClient = () => {
|
const UsersPageClient = ({ currentUserId }) => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -164,6 +164,7 @@ const UsersPageClient = () => {
|
|||||||
|
|
||||||
<UserEditModal
|
<UserEditModal
|
||||||
userId={editingUserId}
|
userId={editingUserId}
|
||||||
|
currentUserId={currentUserId}
|
||||||
isOpen={!!editingUserId}
|
isOpen={!!editingUserId}
|
||||||
onClose={() => setEditingUserId(null)}
|
onClose={() => setEditingUserId(null)}
|
||||||
onSaved={fetchUsers}
|
onSaved={fetchUsers}
|
||||||
@@ -172,10 +173,10 @@ const UsersPageClient = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UsersPage = () => (
|
const UsersPage = ({ user }) => (
|
||||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||||
<AdminHeader title="Utilisateurs" description="Gérez les comptes utilisateurs" />
|
<AdminHeader title="Utilisateurs" description="Gérez les comptes utilisateurs" />
|
||||||
<UsersPageClient />
|
<UsersPageClient currentUserId={user?.id} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+166
-3
@@ -7,10 +7,13 @@
|
|||||||
* the context argument: (request, params, { session }).
|
* the context argument: (request, params, { session }).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { query, updateById } from '@zen/core/database';
|
import { query, updateById, findOne } from '@zen/core/database';
|
||||||
import { updateUser } from './auth.js';
|
import { updateUser } from './auth.js';
|
||||||
|
import { verifyPassword } from '../../core/users/password.js';
|
||||||
|
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail } 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 } 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';
|
||||||
|
|
||||||
const generateUserFilePath = (userId, category, filename) => `users/${userId}/${category}/${filename}`;
|
const generateUserFilePath = (userId, category, filename) => `users/${userId}/${category}/${filename}`;
|
||||||
import { fail, info } from '@zen/core/shared/logger';
|
import { fail, info } from '@zen/core/shared/logger';
|
||||||
@@ -139,6 +142,163 @@ async function handleListUsers(request) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /zen/api/users/profile/email
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
async function handleInitiateEmailChange(request, _params, { session }) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { newEmail, password } = body;
|
||||||
|
|
||||||
|
if (!newEmail || !EMAIL_REGEX.test(newEmail) || newEmail.length > 254) {
|
||||||
|
return apiError('Bad Request', 'Adresse courriel invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
return apiError('Bad Request', 'Le mot de passe est requis');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = newEmail.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (normalizedEmail === session.user.email.toLowerCase()) {
|
||||||
|
return apiError('Bad Request', 'Cette adresse courriel est déjà la vôtre');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
|
||||||
|
if (existing) {
|
||||||
|
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await findOne('zen_auth_accounts', { user_id: session.user.id, provider_id: 'credential' });
|
||||||
|
if (!account || !account.password) {
|
||||||
|
return apiError('Bad Request', 'Impossible de vérifier le mot de passe');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordValid = await verifyPassword(password, account.password);
|
||||||
|
if (!passwordValid) {
|
||||||
|
return apiError('Unauthorized', 'Mot de passe incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await createEmailChangeToken(session.user.id, normalizedEmail);
|
||||||
|
const baseUrl = getPublicBaseUrl();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmailChangeConfirmEmail(normalizedEmail, token, baseUrl);
|
||||||
|
} catch (emailError) {
|
||||||
|
fail(`handleInitiateEmailChange: failed to send confirmation email: ${emailError.message}`);
|
||||||
|
return apiError('Internal Server Error', 'Impossible d\'envoyer le courriel de confirmation');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmailChangeOldNotifyEmail(session.user.email, normalizedEmail, 'pending');
|
||||||
|
} catch (emailError) {
|
||||||
|
fail(`handleInitiateEmailChange: failed to send notification email: ${emailError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return apiSuccess({ success: true, message: `Un courriel de confirmation a été envoyé à ${normalizedEmail}` });
|
||||||
|
} catch (error) {
|
||||||
|
logAndObscureError(error, null);
|
||||||
|
return apiError('Internal Server Error', 'Impossible d\'initier le changement de courriel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /zen/api/users/email/confirm
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleConfirmEmailChange(request, _params, { session }) {
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const token = url.searchParams.get('token');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return apiError('Bad Request', 'Jeton de confirmation manquant');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await verifyEmailChangeToken(token);
|
||||||
|
if (!result) {
|
||||||
|
return apiError('Bad Request', 'Lien de confirmation invalide ou expiré');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId, newEmail } = result;
|
||||||
|
|
||||||
|
if (userId !== session.user.id) {
|
||||||
|
return apiError('Forbidden', 'Ce lien ne vous appartient pas');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await findOne('zen_auth_users', { email: newEmail });
|
||||||
|
if (existing) {
|
||||||
|
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyEmailChange(userId, newEmail);
|
||||||
|
|
||||||
|
return apiSuccess({ success: true, message: 'Adresse courriel mise à jour avec succès' });
|
||||||
|
} catch (error) {
|
||||||
|
logAndObscureError(error, null);
|
||||||
|
return apiError('Internal Server Error', 'Impossible de confirmer le changement de courriel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// PUT /zen/api/users/:id/email (admin only)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function handleAdminUpdateUserEmail(request, { id: userId }) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { newEmail } = body;
|
||||||
|
|
||||||
|
if (!newEmail || !EMAIL_REGEX.test(newEmail) || newEmail.length > 254) {
|
||||||
|
return apiError('Bad Request', 'Adresse courriel invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = newEmail.trim().toLowerCase();
|
||||||
|
|
||||||
|
const targetUser = await findOne('zen_auth_users', { id: userId });
|
||||||
|
if (!targetUser) {
|
||||||
|
return apiError('Not Found', 'Utilisateur introuvable');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEmail === targetUser.email.toLowerCase()) {
|
||||||
|
return apiError('Bad Request', 'Cette adresse courriel est déjà celle de l\'utilisateur');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
|
||||||
|
if (existing) {
|
||||||
|
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldEmail = targetUser.email;
|
||||||
|
await applyEmailChange(userId, normalizedEmail);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmailChangeOldNotifyEmail(oldEmail, normalizedEmail, 'changed');
|
||||||
|
} catch (emailError) {
|
||||||
|
fail(`handleAdminUpdateUserEmail: failed to notify old email ${oldEmail}: ${emailError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmailChangeNewNotifyEmail(normalizedEmail, oldEmail);
|
||||||
|
} catch (emailError) {
|
||||||
|
fail(`handleAdminUpdateUserEmail: failed to notify new email ${normalizedEmail}: ${emailError.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await query(
|
||||||
|
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return apiSuccess({ success: true, user: updated.rows[0], message: 'Courriel mis à jour avec succès' });
|
||||||
|
} catch (error) {
|
||||||
|
logAndObscureError(error, null);
|
||||||
|
return apiError('Internal Server Error', 'Impossible de mettre à jour le courriel');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// PUT /zen/api/users/profile
|
// PUT /zen/api/users/profile
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -467,15 +627,18 @@ async function handleDeleteRole(_request, { id: roleId }) {
|
|||||||
// parameterised paths (/users/:id) so they match first.
|
// parameterised paths (/users/:id) so they match first.
|
||||||
|
|
||||||
export const routes = defineApiRoutes([
|
export const routes = defineApiRoutes([
|
||||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
||||||
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
||||||
|
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, 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/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' },
|
||||||
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
|
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
|
||||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
||||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
|
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
|
||||||
|
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin' },
|
||||||
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
|
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
|
||||||
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
|
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
|
||||||
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
|
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import { sendEmail } from '@zen/core/email';
|
|||||||
import { VerificationEmail } from './templates/VerificationEmail.js';
|
import { VerificationEmail } from './templates/VerificationEmail.js';
|
||||||
import { PasswordResetEmail } from './templates/PasswordResetEmail.js';
|
import { PasswordResetEmail } from './templates/PasswordResetEmail.js';
|
||||||
import { PasswordChangedEmail } from './templates/PasswordChangedEmail.js';
|
import { PasswordChangedEmail } from './templates/PasswordChangedEmail.js';
|
||||||
|
import { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
|
||||||
|
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
|
||||||
|
|
||||||
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
|
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
|
||||||
from '../../core/users/verifications.js';
|
from '../../core/users/verifications.js';
|
||||||
|
|
||||||
|
export { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange }
|
||||||
|
from '../../core/users/emailChange.js';
|
||||||
|
|
||||||
async function sendVerificationEmail(email, token, baseUrl) {
|
async function sendVerificationEmail(email, token, baseUrl) {
|
||||||
const appName = process.env.ZEN_NAME || 'ZEN';
|
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||||
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
|
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
|
||||||
@@ -46,4 +51,46 @@ async function sendPasswordChangedEmail(email) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail };
|
async function sendEmailChangeConfirmEmail(newEmail, token, baseUrl) {
|
||||||
|
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||||
|
const confirmUrl = `${baseUrl}/admin/confirm-email-change?token=${encodeURIComponent(token)}`;
|
||||||
|
const html = await render(<EmailChangeConfirmEmail confirmUrl={confirmUrl} newEmail={newEmail} companyName={appName} />);
|
||||||
|
const result = await sendEmail({ to: newEmail, subject: `Confirmez votre nouvelle adresse courriel – ${appName}`, html });
|
||||||
|
if (!result.success) {
|
||||||
|
fail(`Auth: failed to send email change confirmation to ${newEmail}: ${result.error}`);
|
||||||
|
throw new Error('Failed to send email change confirmation');
|
||||||
|
}
|
||||||
|
info(`Auth: email change confirmation sent to ${newEmail}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmailChangeOldNotifyEmail(oldEmail, newEmail, variant) {
|
||||||
|
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||||
|
const subjects = {
|
||||||
|
pending: `Demande de modification de courriel – ${appName}`,
|
||||||
|
changed: `Votre adresse courriel a été modifiée – ${appName}`,
|
||||||
|
};
|
||||||
|
const subject = subjects[variant] || subjects.changed;
|
||||||
|
const html = await render(<EmailChangeNotifyEmail oldEmail={oldEmail} newEmail={newEmail} variant={variant} companyName={appName} />);
|
||||||
|
const result = await sendEmail({ to: oldEmail, subject, html });
|
||||||
|
if (!result.success) {
|
||||||
|
fail(`Auth: failed to send email change notification to ${oldEmail}: ${result.error}`);
|
||||||
|
throw new Error('Failed to send email change notification');
|
||||||
|
}
|
||||||
|
info(`Auth: email change notification (${variant}) sent to ${oldEmail}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendEmailChangeNewNotifyEmail(newEmail, oldEmail) {
|
||||||
|
const appName = process.env.ZEN_NAME || 'ZEN';
|
||||||
|
const html = await render(<EmailChangeNotifyEmail oldEmail={oldEmail} newEmail={newEmail} variant="admin_new" companyName={appName} />);
|
||||||
|
const result = await sendEmail({ to: newEmail, subject: `Votre compte est maintenant associé à cette adresse – ${appName}`, html });
|
||||||
|
if (!result.success) {
|
||||||
|
fail(`Auth: failed to send email change welcome to ${newEmail}: ${result.error}`);
|
||||||
|
throw new Error('Failed to send email change welcome');
|
||||||
|
}
|
||||||
|
info(`Auth: email change welcome sent to ${newEmail}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail };
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Button, Section, Text, Link } from "@react-email/components";
|
||||||
|
import { BaseLayout } from "@zen/core/email/templates";
|
||||||
|
|
||||||
|
export const EmailChangeConfirmEmail = ({ confirmUrl, newEmail, companyName }) => (
|
||||||
|
<BaseLayout
|
||||||
|
preview={`Confirmez votre nouvelle adresse courriel – ${companyName}`}
|
||||||
|
title="Confirmez votre nouvelle adresse courriel"
|
||||||
|
companyName={companyName}
|
||||||
|
supportSection={true}
|
||||||
|
>
|
||||||
|
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||||
|
Une demande de modification d'adresse courriel a été effectuée sur votre compte{' '}
|
||||||
|
<span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre nouvelle adresse.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
|
||||||
|
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
|
||||||
|
Nouvelle adresse
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] font-medium text-neutral-900 m-0">
|
||||||
|
{newEmail}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section className="mt-[28px] mb-[32px]">
|
||||||
|
<Button
|
||||||
|
href={confirmUrl}
|
||||||
|
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
|
||||||
|
>
|
||||||
|
Confirmer mon adresse courriel
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||||
|
Ce lien expire dans 24 heures. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message — votre adresse actuelle restera inchangée.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||||
|
Lien :{' '}
|
||||||
|
<Link href={confirmUrl} className="text-neutral-400 underline break-all">
|
||||||
|
{confirmUrl}
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Section, Text } from "@react-email/components";
|
||||||
|
import { BaseLayout } from "@zen/core/email/templates";
|
||||||
|
|
||||||
|
const VARIANTS = {
|
||||||
|
pending: {
|
||||||
|
preview: (name) => `Demande de modification de courriel – ${name}`,
|
||||||
|
title: 'Demande de modification de courriel',
|
||||||
|
body: (name) => `Une demande de modification de l'adresse courriel associée à votre compte ${name} a été initiée.`,
|
||||||
|
note: "Si vous n'êtes pas à l'origine de cette demande, contactez immédiatement notre équipe de support. Votre adresse actuelle reste active jusqu'à confirmation.",
|
||||||
|
},
|
||||||
|
changed: {
|
||||||
|
preview: (name) => `Votre adresse courriel a été modifiée – ${name}`,
|
||||||
|
title: 'Adresse courriel modifiée',
|
||||||
|
body: (name) => `L'adresse courriel de votre compte ${name} a été modifiée par un administrateur.`,
|
||||||
|
note: "Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.",
|
||||||
|
},
|
||||||
|
admin_new: {
|
||||||
|
preview: (name) => `Votre compte est maintenant associé à cette adresse – ${name}`,
|
||||||
|
title: 'Adresse courriel associée à votre compte',
|
||||||
|
body: (name) => `Votre adresse courriel est maintenant associée à un compte ${name}. Cette modification a été effectuée par un administrateur.`,
|
||||||
|
note: "Si vous n'avez pas été informé de cette modification, contactez notre équipe de support.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmailChangeNotifyEmail = ({ oldEmail, newEmail, variant = 'changed', companyName }) => {
|
||||||
|
const msg = VARIANTS[variant] || VARIANTS.changed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseLayout
|
||||||
|
preview={msg.preview(companyName)}
|
||||||
|
title={msg.title}
|
||||||
|
companyName={companyName}
|
||||||
|
supportSection={true}
|
||||||
|
>
|
||||||
|
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||||
|
{msg.body(companyName)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
|
||||||
|
{oldEmail && variant !== 'admin_new' && (
|
||||||
|
<>
|
||||||
|
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
|
||||||
|
Ancienne adresse
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] font-medium text-neutral-900 m-0 mb-[12px]">
|
||||||
|
{oldEmail}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
|
||||||
|
Nouvelle adresse
|
||||||
|
</Text>
|
||||||
|
<Text className="text-[14px] font-medium text-neutral-900 m-0">
|
||||||
|
{newEmail}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||||
|
{msg.note}
|
||||||
|
</Text>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user