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:
+66
-2
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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é |
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
|
||||
<AdminHeader title="Utilisateurs" description="Gérez les comptes utilisateurs" />
|
||||
<UsersPageClient currentUserId={user?.id} />
|
||||
<AdminHeader
|
||||
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>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersPage;
|
||||
|
||||
|
||||
@@ -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 <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />;
|
||||
case ConfirmEmailPage:
|
||||
return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />;
|
||||
case SetupAccountPage:
|
||||
return <Page {...common} onSubmit={setupAccountAction} email={email} token={token} />;
|
||||
case LogoutPage:
|
||||
return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />;
|
||||
default:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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é.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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(<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 };
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
Reference in New Issue
Block a user