From abd9d651dce16d2a2c285aa29a10a82c8cfded60 Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 09:03:15 -0400 Subject: [PATCH] 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 --- src/core/users/auth.js | 68 +++++++- src/core/users/verifications.js | 50 +++++- src/features/admin/README.md | 3 +- .../components/UserCreateModal.client.js | 157 ++++++++++++++++++ src/features/admin/components/index.js | 1 + src/features/admin/pages/UsersPage.client.js | 35 +++- src/features/auth/AuthPage.client.js | 5 + src/features/auth/AuthPage.server.js | 2 + src/features/auth/README.md | 47 +++++- src/features/auth/actions.js | 38 ++++- src/features/auth/api.js | 88 +++++++++- src/features/auth/email.js | 16 +- src/features/auth/index.js | 7 +- .../auth/pages/SetupAccountPage.client.js | 149 +++++++++++++++++ src/features/auth/pages/index.js | 1 + .../auth/templates/InvitationEmail.js | 35 ++++ 16 files changed, 681 insertions(+), 21 deletions(-) create mode 100644 src/features/admin/components/UserCreateModal.client.js create mode 100644 src/features/auth/pages/SetupAccountPage.client.js create mode 100644 src/features/auth/templates/InvitationEmail.js diff --git a/src/core/users/auth.js b/src/core/users/auth.js index 018c6ca..bf8f2ad 100644 --- a/src/core/users/auth.js +++ b/src/core/users/auth.js @@ -2,7 +2,7 @@ import { query, create, findOne, updateById, count } from '@zen/core/database'; import { hashPassword, verifyPassword, generateId } from './password.js'; import { createSession } from './session.js'; 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 } = {}) { const { email, password, name } = userData; @@ -228,4 +228,68 @@ async function updateUser(userId, updateData) { 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 }; diff --git a/src/core/users/verifications.js b/src/core/users/verifications.js index a8fad87..d606248 100644 --- a/src/core/users/verifications.js +++ b/src/core/users/verifications.js @@ -98,4 +98,52 @@ function deleteResetToken(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 }; diff --git a/src/features/admin/README.md b/src/features/admin/README.md index 92cebd3..cc83c95 100644 --- a/src/features/admin/README.md +++ b/src/features/admin/README.md @@ -23,6 +23,7 @@ src/features/admin/ │ ├── AdminTop.js │ ├── RoleEditModal.client.js │ ├── ThemeToggle.js +│ ├── UserCreateModal.client.js │ └── UserEditModal.client.js ├── devkit/ │ ├── ComponentsPage.client.js @@ -64,7 +65,7 @@ import { | Route | Page | |-------|------| | `/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/settings` | Paramètres de l'application | | `/admin/profile` | Profil de l'utilisateur connecté | diff --git a/src/features/admin/components/UserCreateModal.client.js b/src/features/admin/components/UserCreateModal.client.js new file mode 100644 index 0000000..4324b85 --- /dev/null +++ b/src/features/admin/components/UserCreateModal.client.js @@ -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 ( + +
+ {error && ( +
+
+
+ {error} +
+
+ )} + +
+ handleInputChange('name', value)} + placeholder="Prénom Nom" + error={errors.name} + /> + handleInputChange('email', value)} + placeholder="utilisateur@exemple.com" + error={errors.email} + /> +
+ + ( + + )} + /> + +
+ handleInputChange('password', value)} + placeholder="Laisser vide pour envoyer une invitation" + /> +

+ Si vide, un courriel d'invitation sera envoyé pour que l'utilisateur crée son mot de passe. +

+
+
+ + ); +}; + +export default UserCreateModal; diff --git a/src/features/admin/components/index.js b/src/features/admin/components/index.js index 42f5146..ac3da66 100644 --- a/src/features/admin/components/index.js +++ b/src/features/admin/components/index.js @@ -7,3 +7,4 @@ export { default as AdminHeader } from './AdminHeader.js'; export { default as ThemeToggle } from './ThemeToggle.js'; export { default as UserEditModal } from './UserEditModal.client.js'; export { default as RoleEditModal } from './RoleEditModal.client.js'; +export { default as UserCreateModal } from './UserCreateModal.client.js'; diff --git a/src/features/admin/pages/UsersPage.client.js b/src/features/admin/pages/UsersPage.client.js index f562959..8c39f25 100644 --- a/src/features/admin/pages/UsersPage.client.js +++ b/src/features/admin/pages/UsersPage.client.js @@ -7,8 +7,9 @@ import { PencilEdit01Icon } from '@zen/core/shared/icons'; import { useToast } from '@zen/core/toast'; import AdminHeader from '../components/AdminHeader.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 [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); @@ -126,7 +127,7 @@ const UsersPageClient = ({ currentUserId }) => { useEffect(() => { fetchUsers(); - }, [sortBy, sortOrder, pagination.page, pagination.limit]); + }, [sortBy, sortOrder, pagination.page, pagination.limit, refreshKey]); const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage })); 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 ( +
+ setCreateModalOpen(true)}> + Nouvel utilisateur + + } + /> + + setCreateModalOpen(false)} + onSaved={() => setRefreshKey(k => k + 1)} + /> +
+ ); +}; 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} +
+
+ )} + +
+
+ setFormData(prev => ({ ...prev, newPassword: value }))} + placeholder="••••••••" + disabled={!!success} + minLength="8" + maxLength="128" + autoComplete="new-password" + required + /> + +
+ + setFormData(prev => ({ ...prev, confirmPassword: value }))} + placeholder="••••••••" + disabled={!!success} + minLength="8" + maxLength="128" + autoComplete="new-password" + required + /> + + +
+ +
+ +
+
+ ); +} 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} + + +
+);