feat(auth): add user invitation flow with account setup

- add `createAccountSetup`, `verifyAccountSetupToken`, `deleteAccountSetupToken` to verifications core
- add `completeAccountSetup` function to auth core for password creation on invite
- add `InvitationEmail` template for sending invite links
- add `SetupAccountPage` client page for invited users to set their password
- add `UserCreateModal` admin component to invite new users
- wire invitation action and API endpoint in auth feature
- update admin `UsersPage` to include user creation modal
- update auth and admin README docs
This commit is contained in:
2026-04-25 09:03:15 -04:00
parent 96c8cf1e97
commit abd9d651dc
16 changed files with 681 additions and 21 deletions
+66 -2
View File
@@ -2,7 +2,7 @@ import { query, create, findOne, updateById, count } from '@zen/core/database';
import { hashPassword, verifyPassword, generateId } from './password.js'; import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js'; import { createSession } from './session.js';
import { fail } from '@zen/core/shared/logger'; import { fail } from '@zen/core/shared/logger';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js'; import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, verifyAccountSetupToken, deleteAccountSetupToken } from './verifications.js';
async function register(userData, { onEmailVerification } = {}) { async function register(userData, { onEmailVerification } = {}) {
const { email, password, name } = userData; const { email, password, name } = userData;
@@ -228,4 +228,68 @@ async function updateUser(userId, updateData) {
return await updateById('zen_auth_users', userId, filteredData); return await updateById('zen_auth_users', userId, filteredData);
} }
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser }; async function completeAccountSetup({ email, token, password }) {
if (!email || !token || !password) {
throw new Error('L\'e-mail, le jeton et le mot de passe sont requis');
}
if (password.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
const tokenValid = await verifyAccountSetupToken(email, token);
if (!tokenValid) {
throw new Error('Lien d\'invitation invalide ou expiré');
}
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Lien d\'invitation invalide');
}
const hashedPassword = await hashPassword(password);
const existingAccount = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (existingAccount) {
await updateById('zen_auth_accounts', existingAccount.id, {
password: hashedPassword,
updated_at: new Date()
});
} else {
await create('zen_auth_accounts', {
id: generateId(),
account_id: email,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
}
await updateById('zen_auth_users', user.id, {
email_verified: true,
updated_at: new Date()
});
await deleteAccountSetupToken(email);
return { success: true };
}
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser, completeAccountSetup };
+49 -1
View File
@@ -98,4 +98,52 @@ function deleteResetToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email }); return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
} }
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }; async function createAccountSetup(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 48);
await deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
const setup = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'account_setup',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...setup, token };
}
async function verifyAccountSetupToken(email, token) {
const setup = await findOne('zen_auth_verifications', {
identifier: 'account_setup',
value: email
});
if (!setup) return false;
const storedBuf = Buffer.from(setup.token, 'utf8');
const providedBuf = Buffer.from(
token.length === setup.token.length ? token : setup.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === setup.token.length;
if (!tokensMatch) return false;
if (new Date(setup.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: setup.id });
return false;
}
return true;
}
function deleteAccountSetupToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
}
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken, createAccountSetup, verifyAccountSetupToken, deleteAccountSetupToken };
+2 -1
View File
@@ -23,6 +23,7 @@ src/features/admin/
│ ├── AdminTop.js │ ├── AdminTop.js
│ ├── RoleEditModal.client.js │ ├── RoleEditModal.client.js
│ ├── ThemeToggle.js │ ├── ThemeToggle.js
│ ├── UserCreateModal.client.js
│ └── UserEditModal.client.js │ └── UserEditModal.client.js
├── devkit/ ├── devkit/
│ ├── ComponentsPage.client.js │ ├── ComponentsPage.client.js
@@ -64,7 +65,7 @@ import {
| Route | Page | | Route | Page |
|-------|------| |-------|------|
| `/admin/dashboard` | Tableau de bord avec widgets | | `/admin/dashboard` | Tableau de bord avec widgets |
| `/admin/users` | Liste et gestion des utilisateurs | | `/admin/users` | Liste, création et gestion des utilisateurs |
| `/admin/roles` | Gestion des rôles et permissions | | `/admin/roles` | Gestion des rôles et permissions |
| `/admin/settings` | Paramètres de l'application | | `/admin/settings` | Paramètres de l'application |
| `/admin/profile` | Profil de l'utilisateur connecté | | `/admin/profile` | Profil de l'utilisateur connecté |
@@ -0,0 +1,157 @@
'use client';
import { useState, useEffect } from 'react';
import { Input, TagInput, Modal, RoleBadge } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
const UserCreateModal = ({ isOpen, onClose, onSaved }) => {
const toast = useToast();
const [saving, setSaving] = useState(false);
const [allRoles, setAllRoles] = useState([]);
const [selectedRoleIds, setSelectedRoleIds] = useState([]);
const [formData, setFormData] = useState({ name: '', email: '', password: '' });
const [errors, setErrors] = useState({});
const [error, setError] = useState('');
useEffect(() => {
if (!isOpen) return;
setFormData({ name: '', email: '', password: '' });
setSelectedRoleIds([]);
setErrors({});
setError('');
fetchRoles();
}, [isOpen]);
const fetchRoles = async () => {
try {
const res = await fetch('/zen/api/roles', { credentials: 'include' });
const data = await res.json();
setAllRoles(data.roles || []);
} catch {
toast.error('Impossible de charger les rôles');
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
if (error) setError('');
};
const validate = () => {
const newErrors = {};
if (!formData.name.trim()) newErrors.name = 'Le nom est requis';
if (!formData.email.trim()) newErrors.email = 'Le courriel est requis';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
setSaving(true);
setError('');
try {
const res = await fetch('/zen/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: formData.name.trim(),
email: formData.email.trim(),
password: formData.password || undefined,
roleIds: selectedRoleIds,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || data.error || "Impossible de créer l'utilisateur");
return;
}
if (data.invited) {
toast.success('Utilisateur créé — invitation envoyée par courriel');
} else {
toast.success('Utilisateur créé');
}
onSaved?.();
onClose();
} catch {
setError("Impossible de créer l'utilisateur");
} finally {
setSaving(false);
}
};
const roleOptions = allRoles.map(r => ({
value: r.id,
label: r.name,
color: r.color || '#6b7280',
description: r.description || undefined,
}));
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Nouvel utilisateur"
onSubmit={handleSubmit}
submitLabel="Créer"
loading={saving}
size="md"
>
<div className="flex flex-col gap-4">
{error && (
<div className="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 gap-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>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Nom complet *"
value={formData.name}
onChange={(value) => handleInputChange('name', value)}
placeholder="Prénom Nom"
error={errors.name}
/>
<Input
label="Courriel *"
type="email"
value={formData.email}
onChange={(value) => handleInputChange('email', value)}
placeholder="utilisateur@exemple.com"
error={errors.email}
/>
</div>
<TagInput
label="Rôles"
options={roleOptions}
value={selectedRoleIds}
onChange={setSelectedRoleIds}
placeholder="Rechercher un rôle..."
renderTag={(opt, onRemove) => (
<RoleBadge key={opt.value} name={opt.label} color={opt.color} onRemove={onRemove} />
)}
/>
<div className="flex flex-col gap-1">
<Input
label="Mot de passe"
type="password"
value={formData.password}
onChange={(value) => handleInputChange('password', value)}
placeholder="Laisser vide pour envoyer une invitation"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Si vide, un courriel d'invitation sera envoyé pour que l'utilisateur crée son mot de passe.
</p>
</div>
</div>
</Modal>
);
};
export default UserCreateModal;
+1
View File
@@ -7,3 +7,4 @@ export { default as AdminHeader } from './AdminHeader.js';
export { default as ThemeToggle } from './ThemeToggle.js'; export { default as ThemeToggle } from './ThemeToggle.js';
export { default as UserEditModal } from './UserEditModal.client.js'; export { default as UserEditModal } from './UserEditModal.client.js';
export { default as RoleEditModal } from './RoleEditModal.client.js'; export { default as RoleEditModal } from './RoleEditModal.client.js';
export { default as UserCreateModal } from './UserCreateModal.client.js';
+24 -5
View File
@@ -7,8 +7,9 @@ import { PencilEdit01Icon } from '@zen/core/shared/icons';
import { useToast } from '@zen/core/toast'; 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';
import UserCreateModal from '../components/UserCreateModal.client.js';
const UsersPageClient = ({ currentUserId }) => { const UsersPageClient = ({ currentUserId, refreshKey }) => {
const toast = useToast(); const toast = useToast();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -126,7 +127,7 @@ const UsersPageClient = ({ currentUserId }) => {
useEffect(() => { useEffect(() => {
fetchUsers(); fetchUsers();
}, [sortBy, sortOrder, pagination.page, pagination.limit]); }, [sortBy, sortOrder, pagination.page, pagination.limit, refreshKey]);
const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage })); const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage }));
const handleLimitChange = (newLimit) => setPagination(prev => ({ ...prev, limit: newLimit, page: 1 })); const handleLimitChange = (newLimit) => setPagination(prev => ({ ...prev, limit: newLimit, page: 1 }));
@@ -168,12 +169,30 @@ const UsersPageClient = ({ currentUserId }) => {
); );
}; };
const UsersPage = ({ user }) => ( const UsersPage = ({ user }) => {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
return (
<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
<UsersPageClient currentUserId={user?.id} /> title="Utilisateurs"
description="Gérez les comptes utilisateurs"
action={
<Button variant="primary" onClick={() => setCreateModalOpen(true)}>
Nouvel utilisateur
</Button>
}
/>
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} />
<UserCreateModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSaved={() => setRefreshKey(k => k + 1)}
/>
</div> </div>
); );
};
export default UsersPage; export default UsersPage;
+5
View File
@@ -8,6 +8,7 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage.client.js';
import ResetPasswordPage from './pages/ResetPasswordPage.client.js'; import ResetPasswordPage from './pages/ResetPasswordPage.client.js';
import ConfirmEmailPage from './pages/ConfirmEmailPage.client.js'; import ConfirmEmailPage from './pages/ConfirmEmailPage.client.js';
import LogoutPage from './pages/LogoutPage.client.js'; import LogoutPage from './pages/LogoutPage.client.js';
import SetupAccountPage from './pages/SetupAccountPage.client.js';
const PAGE_COMPONENTS = { const PAGE_COMPONENTS = {
login: LoginPage, login: LoginPage,
@@ -16,6 +17,7 @@ const PAGE_COMPONENTS = {
reset: ResetPasswordPage, reset: ResetPasswordPage,
confirm: ConfirmEmailPage, confirm: ConfirmEmailPage,
logout: LogoutPage, logout: LogoutPage,
setup: SetupAccountPage,
}; };
export default function AuthPage({ export default function AuthPage({
@@ -26,6 +28,7 @@ export default function AuthPage({
forgotPasswordAction, forgotPasswordAction,
resetPasswordAction, resetPasswordAction,
verifyEmailAction, verifyEmailAction,
setupAccountAction,
logoutAction, logoutAction,
setSessionCookieAction, setSessionCookieAction,
redirectAfterLogin = '/', redirectAfterLogin = '/',
@@ -81,6 +84,8 @@ export default function AuthPage({
return <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />; return <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />;
case ConfirmEmailPage: case ConfirmEmailPage:
return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />; return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />;
case SetupAccountPage:
return <Page {...common} onSubmit={setupAccountAction} email={email} token={token} />;
case LogoutPage: case LogoutPage:
return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />; return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />;
default: default:
+2
View File
@@ -6,6 +6,7 @@ import {
forgotPasswordAction, forgotPasswordAction,
resetPasswordAction, resetPasswordAction,
verifyEmailAction, verifyEmailAction,
setupAccountAction,
setSessionCookie, setSessionCookie,
getSession, getSession,
} from './actions.js'; } from './actions.js';
@@ -25,6 +26,7 @@ export default async function AuthPage({ params, searchParams }) {
forgotPasswordAction={forgotPasswordAction} forgotPasswordAction={forgotPasswordAction}
resetPasswordAction={resetPasswordAction} resetPasswordAction={resetPasswordAction}
verifyEmailAction={verifyEmailAction} verifyEmailAction={verifyEmailAction}
setupAccountAction={setupAccountAction}
setSessionCookieAction={setSessionCookie} setSessionCookieAction={setSessionCookie}
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'} redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
currentUser={session?.user || null} currentUser={session?.user || null}
+45 -2
View File
@@ -11,7 +11,7 @@ src/features/auth/
├── index.js barrel serveur ├── index.js barrel serveur
├── actions.js server actions Next.js ('use server') ├── actions.js server actions Next.js ('use server')
├── api.js routes API REST (users, roles) ├── api.js routes API REST (users, roles)
├── auth.js register, login, resetPassword, updateUser ├── auth.js register, login, resetPassword, updateUser, completeAccountSetup
├── session.js createSession, validateSession, deleteSession ├── session.js createSession, validateSession, deleteSession
├── email.js tokens de vérification + envoi des e-mails ├── email.js tokens de vérification + envoi des e-mails
├── password.js hashPassword, verifyPassword, generateToken ├── password.js hashPassword, verifyPassword, generateToken
@@ -29,13 +29,15 @@ src/features/auth/
│ ├── ForgotPasswordPage.client.js │ ├── ForgotPasswordPage.client.js
│ ├── ResetPasswordPage.client.js │ ├── ResetPasswordPage.client.js
│ ├── ConfirmEmailPage.client.js │ ├── ConfirmEmailPage.client.js
│ ├── SetupAccountPage.client.js
│ └── LogoutPage.client.js │ └── LogoutPage.client.js
└── templates/ └── templates/
├── VerificationEmail.js ├── VerificationEmail.js
├── PasswordResetEmail.js ├── PasswordResetEmail.js
├── PasswordChangedEmail.js ├── PasswordChangedEmail.js
├── EmailChangeConfirmEmail.js ├── EmailChangeConfirmEmail.js
── EmailChangeNotifyEmail.js ── EmailChangeNotifyEmail.js
└── InvitationEmail.js
``` ```
--- ---
@@ -66,6 +68,7 @@ export { default } from '@zen/core/features/auth/server';
| `/auth/forgot` | Mot de passe oublié | | `/auth/forgot` | Mot de passe oublié |
| `/auth/reset` | Réinitialisation du mot de passe | | `/auth/reset` | Réinitialisation du mot de passe |
| `/auth/confirm` | Vérification de l'adresse courriel | | `/auth/confirm` | Vérification de l'adresse courriel |
| `/auth/setup` | Configuration du compte après invitation admin |
| `/auth/logout` | Déconnexion | | `/auth/logout` | Déconnexion |
--- ---
@@ -144,6 +147,19 @@ Vérifie le token de confirmation et marque l'adresse comme vérifiée.
--- ---
### `setupAccountAction(formData)`
Vérifie le token d'invitation, crée le compte credential et marque l'adresse comme vérifiée. Appelée depuis `/auth/setup` après qu'un admin a créé le compte sans mot de passe.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `token` | Token reçu dans le courriel d'invitation |
| `newPassword` | Mot de passe choisi |
| `confirmPassword` | Confirmation du mot de passe |
---
### `setSessionCookie(token)` ### `setSessionCookie(token)`
Valide le token contre la base avant de l'écrire dans le cookie `HttpOnly`. À utiliser après une authentification externe ou OAuth. Valide le token contre la base avant de l'écrire dans le cookie `HttpOnly`. À utiliser après une authentification externe ou OAuth.
@@ -165,6 +181,7 @@ Les routes sont enregistrées automatiquement sous le préfixe `/zen/api`. L'aut
| Méthode | Route | Auth | Description | | Méthode | Route | Auth | Description |
|---------|-------|------|-------------| |---------|-------|------|-------------|
| `GET` | `/zen/api/users` | admin | Liste paginée des utilisateurs | | `GET` | `/zen/api/users` | admin | Liste paginée des utilisateurs |
| `POST` | `/zen/api/users` | admin | Créer un utilisateur (avec ou sans invitation) |
| `GET` | `/zen/api/users/:id` | admin | Détail d'un utilisateur | | `GET` | `/zen/api/users/:id` | admin | Détail d'un utilisateur |
| `PUT` | `/zen/api/users/:id` | admin | Modifier `name`, `role`, `email_verified` | | `PUT` | `/zen/api/users/:id` | admin | Modifier `name`, `role`, `email_verified` |
| `PUT` | `/zen/api/users/:id/email` | admin | Changer l'adresse courriel | | `PUT` | `/zen/api/users/:id/email` | admin | Changer l'adresse courriel |
@@ -200,6 +217,32 @@ Les routes sont enregistrées automatiquement sous le préfixe `/zen/api`. L'aut
--- ---
## Invitation par l'admin
Un administrateur peut créer un utilisateur depuis `/admin/users → Nouvel utilisateur`. Deux flux selon si un mot de passe est fourni :
**Avec mot de passe :** l'utilisateur est créé avec `email_verified = true` et un compte credential. Il peut se connecter immédiatement.
**Sans mot de passe :** l'utilisateur est créé avec `email_verified = false` et aucun compte credential. Un token `account_setup` (48 h) est généré et un courriel d'invitation est envoyé. L'utilisateur clique sur le lien `/auth/setup?email=X&token=Y`, choisit son mot de passe, et le compte est activé (`email_verified = true`) en une seule étape — le passage par le lien vaut confirmation du courriel.
```
Admin crée l'utilisateur (sans mdp)
→ POST /zen/api/users
→ zen_auth_users créé (email_verified: false)
→ token account_setup enregistré dans zen_auth_verifications (48 h)
→ courriel InvitationEmail envoyé
Utilisateur clique sur le lien /auth/setup
→ SetupAccountPage (setupAccountAction)
→ token vérifié
→ zen_auth_accounts créé avec mot de passe haché
→ email_verified = true
→ token supprimé
→ redirection vers /auth/login
```
---
## Sécurité ## Sécurité
**Rate limiting par IP.** Les actions `register`, `login`, `forgot_password`, `reset_password` et `verify_email` sont limitées par adresse IP. Quand l'IP est inconnue (pas de proxy configuré), le rate limiting est suspendu et un avertissement opérateur est émis une seule fois. Activer avec `ZEN_TRUST_PROXY=true` derrière un reverse proxy vérifié. **Rate limiting par IP.** Les actions `register`, `login`, `forgot_password`, `reset_password` et `verify_email` sont limitées par adresse IP. Quand l'IP est inconnue (pas de proxy configuré), le rate limiting est suspendu et un avertissement opérateur est émis une seule fois. Activer avec `ZEN_TRUST_PROXY=true` derrière un reverse proxy vérifié.
+37 -1
View File
@@ -1,6 +1,6 @@
'use server'; 'use server';
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from './auth.js'; import { register, login, requestPasswordReset, resetPassword, verifyUserEmail, completeAccountSetup } from './auth.js';
import { validateSession, deleteSession } from './session.js'; import { validateSession, deleteSession } from './session.js';
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js'; import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
import { fail } from '@zen/core/shared/logger'; import { fail } from '@zen/core/shared/logger';
@@ -323,6 +323,42 @@ export async function resetPasswordAction(formData) {
} }
} }
export async function setupAccountAction(formData) {
try {
const ip = await getClientIp();
const rl = enforceRateLimit(ip, 'setup_account');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const token = formData.get('token');
const newPassword = formData.get('newPassword');
const confirmPassword = formData.get('confirmPassword');
if (!newPassword || !confirmPassword) {
throw new UserFacingError('Les deux champs de mot de passe sont requis');
}
if (newPassword !== confirmPassword) {
throw new UserFacingError('Les mots de passe ne correspondent pas');
}
await completeAccountSetup({ email, token, password: newPassword });
return {
success: true,
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.'
};
} catch (error) {
if (error instanceof UserFacingError) {
return { success: false, error: error.message };
}
fail(`Auth: setupAccountAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
export async function verifyEmailAction(formData) { export async function verifyEmailAction(formData) {
try { try {
const ip = await getClientIp(); const ip = await getClientIp();
+85 -3
View File
@@ -7,10 +7,11 @@
* the context argument: (request, params, { session }). * the context argument: (request, params, { session }).
*/ */
import { query, updateById, findOne } from '@zen/core/database'; import { query, create, 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, generateId } 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, sendInvitationEmail } from './email.js';
import { createAccountSetup } from '../../core/users/verifications.js';
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions } 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';
@@ -807,6 +808,86 @@ async function handleAdminSendPasswordReset(_request, { id: userId }) {
} }
} }
// ---------------------------------------------------------------------------
// POST /zen/api/users (admin only)
// ---------------------------------------------------------------------------
async function handleAdminCreateUser(request) {
try {
const body = await request.json();
const { name, email, password, roleIds } = body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return apiError('Bad Request', 'Le nom est requis');
}
if (name.trim().length > 100) {
return apiError('Bad Request', 'Le nom doit contenir 100 caractères ou moins');
}
if (!email || !EMAIL_REGEX.test(email) || email.length > 254) {
return apiError('Bad Request', 'Adresse courriel invalide');
}
const normalizedEmail = email.trim().toLowerCase();
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
if (existing) {
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
}
const userId = generateId();
const hasPassword = password && typeof password === 'string' && password.length > 0;
const user = await create('zen_auth_users', {
id: userId,
email: normalizedEmail,
name: name.trim(),
email_verified: hasPassword,
image: null,
role: 'user',
updated_at: new Date()
});
if (hasPassword) {
const hashedPassword = await hashPassword(password);
await create('zen_auth_accounts', {
id: generateId(),
account_id: normalizedEmail,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
} else {
const setup = await createAccountSetup(normalizedEmail);
const baseUrl = getPublicBaseUrl();
try {
await sendInvitationEmail(normalizedEmail, setup.token, baseUrl);
} catch (emailError) {
fail(`handleAdminCreateUser: failed to send invitation email to ${normalizedEmail}: ${emailError.message}`);
}
}
if (Array.isArray(roleIds) && roleIds.length > 0) {
for (const roleId of roleIds) {
if (typeof roleId === 'string' && roleId.length > 0) {
try {
await assignUserRole(user.id, roleId);
} catch (err) {
fail(`handleAdminCreateUser: failed to assign role ${roleId} to user ${user.id}: ${err.message}`);
}
}
}
}
return apiSuccess({ user, invited: !hasPassword });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de créer l\'utilisateur');
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Route definitions // Route definitions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -816,6 +897,7 @@ async function handleAdminSendPasswordReset(_request, { id: userId }) {
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', method: 'POST', handler: handleAdminCreateUser, 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/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
{ path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' }, { path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' },
+15 -1
View File
@@ -6,6 +6,7 @@ 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 { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js'; import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
import { InvitationEmail } from './templates/InvitationEmail.js';
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
from '../../core/users/verifications.js'; from '../../core/users/verifications.js';
@@ -93,4 +94,17 @@ async function sendEmailChangeNewNotifyEmail(newEmail, oldEmail) {
return result; return result;
} }
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail }; async function sendInvitationEmail(email, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN';
const setupUrl = `${baseUrl}/auth/setup?email=${encodeURIComponent(email)}&token=${token}`;
const html = await render(<InvitationEmail setupUrl={setupUrl} companyName={appName} />);
const result = await sendEmail({ to: email, subject: `Terminez la création de votre compte ${appName}`, html });
if (!result.success) {
fail(`Auth: failed to send invitation email to ${email}: ${result.error}`);
throw new Error('Failed to send invitation email');
}
info(`Auth: invitation email sent to ${email}`);
return result;
}
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendInvitationEmail };
+5 -2
View File
@@ -9,7 +9,8 @@ export {
requestPasswordReset, requestPasswordReset,
resetPassword, resetPassword,
verifyUserEmail, verifyUserEmail,
updateUser updateUser,
completeAccountSetup
} from './auth.js'; } from './auth.js';
export { export {
@@ -28,7 +29,8 @@ export {
deleteResetToken, deleteResetToken,
sendVerificationEmail, sendVerificationEmail,
sendPasswordResetEmail, sendPasswordResetEmail,
sendPasswordChangedEmail sendPasswordChangedEmail,
sendInvitationEmail
} from './email.js'; } from './email.js';
export { export {
@@ -46,6 +48,7 @@ export {
forgotPasswordAction, forgotPasswordAction,
resetPasswordAction, resetPasswordAction,
verifyEmailAction, verifyEmailAction,
setupAccountAction,
setSessionCookie, setSessionCookie,
refreshSessionCookie refreshSessionCookie
} from './actions.js'; } from './actions.js';
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { Card, Input, Button, PasswordStrengthIndicator } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function SetupAccountPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({ newPassword: '', confirmPassword: '' });
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) errors.push('Le mot de passe doit contenir au moins 8 caractères');
if (password.length > 128) errors.push('Le mot de passe doit contenir 128 caractères ou moins');
if (!/[A-Z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une majuscule');
if (!/[a-z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une minuscule');
if (!/\d/.test(password)) errors.push('Le mot de passe doit contenir au moins un chiffre');
return errors;
};
const isFormValid = () => {
return validatePassword(formData.newPassword).length === 0 &&
formData.newPassword === formData.confirmPassword &&
formData.newPassword.length > 0;
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
const passwordErrors = validatePassword(formData.newPassword);
if (passwordErrors.length > 0) { setError(passwordErrors[0]); setIsLoading(false); return; }
if (formData.newPassword !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
return;
}
const submitData = new FormData();
submitData.append('newPassword', formData.newPassword);
submitData.append('confirmPassword', formData.confirmPassword);
submitData.append('email', email);
submitData.append('token', token);
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
setTimeout(() => onNavigate('login'), 2000);
} else {
setError(result.error || 'Impossible de créer le mot de passe');
setIsLoading(false);
}
} catch (err) {
console.error('Setup account error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader
title="Créez votre mot de passe"
description="Un administrateur a créé votre compte. Choisissez un mot de passe pour y accéder."
/>
{error && !success && (
<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"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</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"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<Input
id="newPassword"
name="newPassword"
type="password"
label="Mot de passe"
value={formData.newPassword}
onChange={(value) => setFormData(prev => ({ ...prev, newPassword: value }))}
placeholder="••••••••"
disabled={!!success}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
</div>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
label="Confirmer le mot de passe"
value={formData.confirmPassword}
onChange={(value) => setFormData(prev => ({ ...prev, confirmPassword: value }))}
placeholder="••••••••"
disabled={!!success}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<Button
type="submit"
variant="primary"
size="lg"
loading={isLoading}
disabled={!!success || !isFormValid()}
className="w-full mt-2"
>
Créer mon mot de passe
</Button>
</form>
<div className="mt-6 flex justify-center">
<Button
type="button"
variant="fullghost"
onClick={() => onNavigate('login')}
>
Retour à la connexion
</Button>
</div>
</Card>
);
}
+1
View File
@@ -4,3 +4,4 @@ export { default as ForgotPasswordPage } from './ForgotPasswordPage.client.js';
export { default as ResetPasswordPage } from './ResetPasswordPage.client.js'; export { default as ResetPasswordPage } from './ResetPasswordPage.client.js';
export { default as ConfirmEmailPage } from './ConfirmEmailPage.client.js'; export { default as ConfirmEmailPage } from './ConfirmEmailPage.client.js';
export { default as LogoutPage } from './LogoutPage.client.js'; export { default as LogoutPage } from './LogoutPage.client.js';
export { default as SetupAccountPage } from './SetupAccountPage.client.js';
@@ -0,0 +1,35 @@
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "@zen/core/email/templates";
export const InvitationEmail = ({ setupUrl, companyName }) => (
<BaseLayout
preview={`Terminez la création de votre compte ${companyName}`}
title="Bienvenue — créez votre mot de passe"
companyName={companyName}
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Un administrateur a créé un compte pour vous sur <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour choisir votre mot de passe et accéder à votre compte.
</Text>
<Section className="mt-[28px] mb-[32px]">
<Button
href={setupUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Créer mon mot de passe
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 48 heures. Si vous n'attendiez pas cette invitation, ignorez ce message.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={setupUrl} className="text-neutral-400 underline break-all">
{setupUrl}
</Link>
</Text>
</BaseLayout>
);