+ );
+};
export default UsersPage;
diff --git a/src/features/auth/AuthPage.client.js b/src/features/auth/AuthPage.client.js
index 205dc68..ea57bd4 100644
--- a/src/features/auth/AuthPage.client.js
+++ b/src/features/auth/AuthPage.client.js
@@ -8,6 +8,7 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage.client.js';
import ResetPasswordPage from './pages/ResetPasswordPage.client.js';
import ConfirmEmailPage from './pages/ConfirmEmailPage.client.js';
import LogoutPage from './pages/LogoutPage.client.js';
+import SetupAccountPage from './pages/SetupAccountPage.client.js';
const PAGE_COMPONENTS = {
login: LoginPage,
@@ -16,6 +17,7 @@ const PAGE_COMPONENTS = {
reset: ResetPasswordPage,
confirm: ConfirmEmailPage,
logout: LogoutPage,
+ setup: SetupAccountPage,
};
export default function AuthPage({
@@ -26,6 +28,7 @@ export default function AuthPage({
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
+ setupAccountAction,
logoutAction,
setSessionCookieAction,
redirectAfterLogin = '/',
@@ -81,6 +84,8 @@ export default function AuthPage({
return ;
case ConfirmEmailPage:
return ;
+ case SetupAccountPage:
+ return ;
case LogoutPage:
return ;
default:
diff --git a/src/features/auth/AuthPage.server.js b/src/features/auth/AuthPage.server.js
index e4e2be3..fa2eaa8 100644
--- a/src/features/auth/AuthPage.server.js
+++ b/src/features/auth/AuthPage.server.js
@@ -6,6 +6,7 @@ import {
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
+ setupAccountAction,
setSessionCookie,
getSession,
} from './actions.js';
@@ -25,6 +26,7 @@ export default async function AuthPage({ params, searchParams }) {
forgotPasswordAction={forgotPasswordAction}
resetPasswordAction={resetPasswordAction}
verifyEmailAction={verifyEmailAction}
+ setupAccountAction={setupAccountAction}
setSessionCookieAction={setSessionCookie}
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
currentUser={session?.user || null}
diff --git a/src/features/auth/README.md b/src/features/auth/README.md
index 32e144a..4211135 100644
--- a/src/features/auth/README.md
+++ b/src/features/auth/README.md
@@ -11,7 +11,7 @@ src/features/auth/
├── index.js barrel serveur
├── actions.js server actions Next.js ('use server')
├── 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
├── email.js tokens de vérification + envoi des e-mails
├── password.js hashPassword, verifyPassword, generateToken
@@ -29,13 +29,15 @@ src/features/auth/
│ ├── ForgotPasswordPage.client.js
│ ├── ResetPasswordPage.client.js
│ ├── ConfirmEmailPage.client.js
+│ ├── SetupAccountPage.client.js
│ └── LogoutPage.client.js
└── templates/
├── VerificationEmail.js
├── PasswordResetEmail.js
├── PasswordChangedEmail.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/reset` | Réinitialisation du mot de passe |
| `/auth/confirm` | Vérification de l'adresse courriel |
+| `/auth/setup` | Configuration du compte après invitation admin |
| `/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)`
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 |
|---------|-------|------|-------------|
| `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 |
| `PUT` | `/zen/api/users/:id` | admin | Modifier `name`, `role`, `email_verified` |
| `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é
**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é.
diff --git a/src/features/auth/actions.js b/src/features/auth/actions.js
index ed81e8c..65df9b3 100644
--- a/src/features/auth/actions.js
+++ b/src/features/auth/actions.js
@@ -1,6 +1,6 @@
'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 { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
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) {
try {
const ip = await getClientIp();
diff --git a/src/features/auth/api.js b/src/features/auth/api.js
index d57fb7b..2372296 100644
--- a/src/features/auth/api.js
+++ b/src/features/auth/api.js
@@ -7,10 +7,11 @@
* 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 { hashPassword, verifyPassword } from '../../core/users/password.js';
-import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail } from './email.js';
+import { hashPassword, verifyPassword, generateId } from '../../core/users/password.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 { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
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
// ---------------------------------------------------------------------------
@@ -816,6 +897,7 @@ async function handleAdminSendPasswordReset(_request, { id: userId }) {
export const routes = defineApiRoutes([
{ 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/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
{ path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' },
diff --git a/src/features/auth/email.js b/src/features/auth/email.js
index c8a4b62..ce26175 100644
--- a/src/features/auth/email.js
+++ b/src/features/auth/email.js
@@ -6,6 +6,7 @@ import { PasswordResetEmail } from './templates/PasswordResetEmail.js';
import { PasswordChangedEmail } from './templates/PasswordChangedEmail.js';
import { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
+import { InvitationEmail } from './templates/InvitationEmail.js';
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
from '../../core/users/verifications.js';
@@ -93,4 +94,17 @@ async function sendEmailChangeNewNotifyEmail(newEmail, oldEmail) {
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();
+ 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 };
diff --git a/src/features/auth/index.js b/src/features/auth/index.js
index 5259fea..287990c 100644
--- a/src/features/auth/index.js
+++ b/src/features/auth/index.js
@@ -9,7 +9,8 @@ export {
requestPasswordReset,
resetPassword,
verifyUserEmail,
- updateUser
+ updateUser,
+ completeAccountSetup
} from './auth.js';
export {
@@ -28,7 +29,8 @@ export {
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
- sendPasswordChangedEmail
+ sendPasswordChangedEmail,
+ sendInvitationEmail
} from './email.js';
export {
@@ -46,6 +48,7 @@ export {
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
+ setupAccountAction,
setSessionCookie,
refreshSessionCookie
} from './actions.js';
diff --git a/src/features/auth/pages/SetupAccountPage.client.js b/src/features/auth/pages/SetupAccountPage.client.js
new file mode 100644
index 0000000..ec1ac3c
--- /dev/null
+++ b/src/features/auth/pages/SetupAccountPage.client.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 (
+
+
+
+ {error && !success && (
+
+
+
+ {error}
+
+
+ )}
+
+ {success && (
+
+
+
+ {success}
+
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/auth/pages/index.js b/src/features/auth/pages/index.js
index cc9dde1..1fb6c87 100644
--- a/src/features/auth/pages/index.js
+++ b/src/features/auth/pages/index.js
@@ -4,3 +4,4 @@ export { default as ForgotPasswordPage } from './ForgotPasswordPage.client.js';
export { default as ResetPasswordPage } from './ResetPasswordPage.client.js';
export { default as ConfirmEmailPage } from './ConfirmEmailPage.client.js';
export { default as LogoutPage } from './LogoutPage.client.js';
+export { default as SetupAccountPage } from './SetupAccountPage.client.js';
diff --git a/src/features/auth/templates/InvitationEmail.js b/src/features/auth/templates/InvitationEmail.js
new file mode 100644
index 0000000..c362507
--- /dev/null
+++ b/src/features/auth/templates/InvitationEmail.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 }) => (
+
+
+ Un administrateur a créé un compte pour vous sur {companyName}. Cliquez sur le bouton ci-dessous pour choisir votre mot de passe et accéder à votre compte.
+
+
+
+
+
+
+
+ Ce lien expire dans 48 heures. Si vous n'attendiez pas cette invitation, ignorez ce message.
+
+
+
+ Lien :{' '}
+
+ {setupUrl}
+
+
+
+);