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:
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user