Checkpoint: Ajout de la fonctionnalité complète de réinitialisation de mot de passe avec génération de tokens temporaires (24h), stockage en base de données, et envoi d'emails automatiques. Nouveau bouton dans la page de gestion des utilisateurs avec dialogue de confirmation.

This commit is contained in:
Manus Sandbox
2025-11-17 16:21:11 -05:00
parent c9f6f8f068
commit 155c4592e1
10 changed files with 832 additions and 1 deletions

View File

@@ -0,0 +1,18 @@
{
"query": "SELECT id, userId, LEFT(token, 20) as token_preview, expiresAt, used, createdAt FROM passwordResetTokens ORDER BY createdAt DESC LIMIT 5;",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute SELECT id, userId, LEFT(token, 20) as token_preview, expiresAt, used, createdAt FROM passwordResetTokens ORDER BY createdAt DESC LIMIT 5;",
"rows": [
{
"id": "1",
"userId": "1",
"token_preview": "e6626bd6ba7eabccd094",
"expiresAt": "2025-11-18 21:19:50",
"used": "0",
"createdAt": "2025-11-17 21:19:50"
}
],
"messages": [],
"stdout": "id\tuserId\ttoken_preview\texpiresAt\tused\tcreatedAt\n1\t1\te6626bd6ba7eabccd094\t2025-11-18 21:19:50\t0\t2025-11-17 21:19:50\n",
"stderr": "",
"execution_time_ms": 901
}

View File

@@ -28,7 +28,7 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
import { Pencil, Trash2, UserCheck, UserX, Search, Plus } from "lucide-react"; import { Pencil, Trash2, UserCheck, UserX, Search, Plus, KeyRound } from "lucide-react";
export default function AdminUsers() { export default function AdminUsers() {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
@@ -37,6 +37,7 @@ export default function AdminUsers() {
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<any>(null); const [selectedUser, setSelectedUser] = useState<any>(null);
const [createForm, setCreateForm] = useState({ const [createForm, setCreateForm] = useState({
openId: "", openId: "",
@@ -101,6 +102,16 @@ export default function AdminUsers() {
}, },
}); });
const resetPasswordMutation = trpc.users.requestPasswordReset.useMutation({
onSuccess: () => {
toast.success("Email de réinitialisation envoyé avec succès");
setResetPasswordDialogOpen(false);
},
onError: (error) => {
toast.error(`Erreur : ${error.message}`);
},
});
const handleCreate = () => { const handleCreate = () => {
setCreateDialogOpen(true); setCreateDialogOpen(true);
}; };
@@ -158,6 +169,16 @@ export default function AdminUsers() {
deleteMutation.mutate({ id: selectedUser.id }); deleteMutation.mutate({ id: selectedUser.id });
}; };
const handleResetPassword = (user: any) => {
setSelectedUser(user);
setResetPasswordDialogOpen(true);
};
const confirmResetPassword = () => {
if (!selectedUser) return;
resetPasswordMutation.mutate({ id: selectedUser.id });
};
// Filtrage des utilisateurs // Filtrage des utilisateurs
const filteredUsers = users.filter((user) => { const filteredUsers = users.filter((user) => {
const matchesSearch = const matchesSearch =
@@ -301,6 +322,15 @@ export default function AdminUsers() {
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleResetPassword(user)}
title="Réinitialiser le mot de passe"
disabled={!user.email}
>
<KeyRound className="h-4 w-4 text-blue-500" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -464,6 +494,42 @@ export default function AdminUsers() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Dialog de réinitialisation de mot de passe */}
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Réinitialiser le mot de passe</DialogTitle>
<DialogDescription>
Êtes-vous sûr de vouloir envoyer un email de réinitialisation de mot de passe à{" "}
<strong>{selectedUser?.name || selectedUser?.email}</strong> ?
</DialogDescription>
</DialogHeader>
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 my-4">
<p className="text-sm text-blue-900">
<strong> Information :</strong>
</p>
<ul className="text-sm text-blue-800 mt-2 space-y-1 list-disc list-inside">
<li>Un email sera envoyé à : <strong>{selectedUser?.email}</strong></li>
<li>Le lien de réinitialisation sera valide pendant 24 heures</li>
<li>Il ne pourra être utilisé qu'une seule fois</li>
</ul>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResetPasswordDialogOpen(false)}>
Annuler
</Button>
<Button
onClick={confirmResetPassword}
disabled={resetPasswordMutation.isPending}
>
{resetPasswordMutation.isPending ? "Envoi en cours..." : "Envoyer l'email"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog de suppression */} {/* Dialog de suppression */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent> <DialogContent>

View File

@@ -0,0 +1,10 @@
CREATE TABLE `passwordResetTokens` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`token` varchar(255) NOT NULL,
`expiresAt` timestamp NOT NULL,
`used` boolean NOT NULL DEFAULT false,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `passwordResetTokens_id` PRIMARY KEY(`id`),
CONSTRAINT `passwordResetTokens_token_unique` UNIQUE(`token`)
);

View File

@@ -0,0 +1,568 @@
{
"version": "5",
"dialect": "mysql",
"id": "44a452bc-0f54-464a-98ae-043474047a69",
"prevId": "0ad1d66e-8e34-4ec9-9e4a-12ff775a6d1b",
"tables": {
"apprenants": {
"name": "apprenants",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"prenom": {
"name": "prenom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"codeEtablissement": {
"name": "codeEtablissement",
"type": "varchar(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"fonction": {
"name": "fonction",
"type": "enum('directeur','chef_service','autre')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"apprenants_id": {
"name": "apprenants_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"apprenants_email_unique": {
"name": "apprenants_email_unique",
"columns": [
"email"
]
}
},
"checkConstraint": {}
},
"datesFormation": {
"name": "datesFormation",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"sequenceId": {
"name": "sequenceId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateDebut": {
"name": "dateDebut",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateFin": {
"name": "dateFin",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ordre": {
"name": "ordre",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"datesFormation_id": {
"name": "datesFormation_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"formations": {
"name": "formations",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"lienUnique": {
"name": "lienUnique",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"actif": {
"name": "actif",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"formations_id": {
"name": "formations_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"formations_lienUnique_unique": {
"name": "formations_lienUnique_unique",
"columns": [
"lienUnique"
]
}
},
"checkConstraint": {}
},
"inscriptions": {
"name": "inscriptions",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"apprenantId": {
"name": "apprenantId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sequenceId": {
"name": "sequenceId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('confirmee','liste_attente','annulee')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dateInscription": {
"name": "dateInscription",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"inscriptions_id": {
"name": "inscriptions_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"passwordResetTokens": {
"name": "passwordResetTokens",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"userId": {
"name": "userId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiresAt": {
"name": "expiresAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"passwordResetTokens_id": {
"name": "passwordResetTokens_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"passwordResetTokens_token_unique": {
"name": "passwordResetTokens_token_unique",
"columns": [
"token"
]
}
},
"checkConstraint": {}
},
"sequences": {
"name": "sequences",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"formationId": {
"name": "formationId",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"nom": {
"name": "nom",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"lieu": {
"name": "lieu",
"type": "varchar(500)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"publicCible": {
"name": "publicCible",
"type": "enum('directeur','chef_service','autre')",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"capaciteMax": {
"name": "capaciteMax",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 12
},
"dateBlocage": {
"name": "dateBlocage",
"type": "datetime",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"statut": {
"name": "statut",
"type": "enum('ouverte','bloquee','terminee')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ouverte'"
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"sequences_id": {
"name": "sequences_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": true
},
"openId": {
"name": "openId",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loginMethod": {
"name": "loginMethod",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "enum('user','admin')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"isActive": {
"name": "isActive",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
},
"updatedAt": {
"name": "updatedAt",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"onUpdate": true,
"default": "(now())"
},
"lastSignedIn": {
"name": "lastSignedIn",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(now())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"users_id": {
"name": "users_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"users_openId_unique": {
"name": "users_openId_unique",
"columns": [
"openId"
]
}
},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@@ -50,6 +50,13 @@
"when": 1763412717239, "when": 1763412717239,
"tag": "0006_lame_madrox", "tag": "0006_lame_madrox",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "5",
"when": 1763414014386,
"tag": "0007_awesome_genesis",
"breakpoints": true
} }
] ]
} }

View File

@@ -122,3 +122,22 @@ export const inscriptions = mysqlTable("inscriptions", {
export type Inscription = typeof inscriptions.$inferSelect; export type Inscription = typeof inscriptions.$inferSelect;
export type InsertInscription = typeof inscriptions.$inferInsert; export type InsertInscription = typeof inscriptions.$inferInsert;
/**
* Table des tokens de réinitialisation de mot de passe
* Stocke les tokens temporaires générés pour la réinitialisation
*/
export const passwordResetTokens = mysqlTable("passwordResetTokens", {
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
/** Token unique généré pour la réinitialisation */
token: varchar("token", { length: 255 }).notNull().unique(),
/** Date d'expiration du token (24h après génération) */
expiresAt: timestamp("expiresAt").notNull(),
/** Indique si le token a été utilisé */
used: boolean("used").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect;
export type InsertPasswordResetToken = typeof passwordResetTokens.$inferInsert;

View File

@@ -8,6 +8,8 @@ import {
sequences, sequences,
datesFormation, datesFormation,
inscriptions, inscriptions,
passwordResetTokens,
InsertPasswordResetToken,
InsertFormation, InsertFormation,
InsertApprenant, InsertApprenant,
InsertSequence, InsertSequence,
@@ -419,3 +421,49 @@ export async function deleteUser(id: number) {
await db.delete(users).where(eq(users.id, id)); await db.delete(users).where(eq(users.id, id));
} }
// ==================== GESTION DES TOKENS DE RÉINITIALISATION ====================
export async function createPasswordResetToken(userId: number, token: string, expiresAt: Date) {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(passwordResetTokens).values({
userId,
token,
expiresAt,
used: false,
});
return result;
}
export async function getPasswordResetToken(token: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select()
.from(passwordResetTokens)
.where(eq(passwordResetTokens.token, token))
.limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function markTokenAsUsed(tokenId: number) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(passwordResetTokens)
.set({ used: true })
.where(eq(passwordResetTokens.id, tokenId));
}
export async function deleteExpiredTokens() {
const db = await getDb();
if (!db) throw new Error("Database not available");
const now = new Date();
await db.delete(passwordResetTokens)
.where(sql`${passwordResetTokens.expiresAt} < ${now}`);
}

View File

@@ -339,3 +339,48 @@ export async function sendGroupEmail(params: {
return { sent, failed }; return { sent, failed };
} }
/**
* Envoie un email de réinitialisation de mot de passe
*/
export async function sendPasswordResetEmail(params: {
apprenantEmail: string;
apprenantNom: string;
apprenantPrenom: string;
resetLink: string;
}): Promise<boolean> {
const content = `
<h2>Réinitialisation de mot de passe</h2>
<p>Bonjour ${params.apprenantPrenom} ${params.apprenantNom},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe pour votre compte Manager Itinova.</p>
<p>Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${params.resetLink}" class="button">Réinitialiser mon mot de passe</a>
</div>
<p>Ou copiez ce lien dans votre navigateur :</p>
<div style="background-color: white; padding: 15px; border-radius: 4px; word-break: break-all; font-family: monospace; font-size: 12px; border: 1px solid #e5e7eb;">
${params.resetLink}
</div>
<div class="info-box" style="background-color: #fef3c7; border-left-color: #f59e0b;">
<p><strong>⚠️ Important :</strong></p>
<ul>
<li>Ce lien est valide pendant <strong>24 heures</strong> seulement</li>
<li>Il ne peut être utilisé qu'<strong>une seule fois</strong></li>
<li>Si vous n'avez pas demandé cette réinitialisation, ignorez cet email</li>
</ul>
</div>
<p>Pour toute question, contactez le service RH.</p>
`;
return sendEmail({
to: params.apprenantEmail,
subject: 'Réinitialisation de votre mot de passe - Manager Itinova',
html: getEmailTemplate(content),
});
}

View File

@@ -583,6 +583,47 @@ export const appRouter = router({
await db.deleteUser(input.id); await db.deleteUser(input.id);
return { success: true }; return { success: true };
}), }),
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
const user = await db.getUserById(input.id);
if (!user) {
throw new Error("Utilisateur introuvable");
}
if (!user.email) {
throw new Error("Cet utilisateur n'a pas d'adresse email");
}
// Générer un token unique
const crypto = await import('crypto');
const token = crypto.randomBytes(32).toString('hex');
// Définir l'expiration à 24h
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
// Sauvegarder le token en base
await db.createPasswordResetToken(user.id, token, expiresAt);
// Générer le lien de réinitialisation
// TODO: Remplacer par l'URL réelle de votre application en production
const resetLink = `https://votre-domaine.com/reset-password?token=${token}`;
// Envoyer l'email
const emailService = await import('./emailService');
const emailSent = await emailService.sendPasswordResetEmail({
apprenantEmail: user.email,
apprenantNom: user.name || 'Utilisateur',
apprenantPrenom: '',
resetLink,
});
if (!emailSent) {
throw new Error("Échec de l'envoi de l'email");
}
return { success: true, message: "Email de réinitialisation envoyé" };
}),
}), }),
}); });

View File

@@ -195,3 +195,12 @@
- [x] Ajouter un bouton "Ajouter un utilisateur" dans la page AdminUsers - [x] Ajouter un bouton "Ajouter un utilisateur" dans la page AdminUsers
- [x] Créer un dialogue de création avec formulaire (nom, email, rôle) - [x] Créer un dialogue de création avec formulaire (nom, email, rôle)
- [x] Tester la création d'un nouvel utilisateur - [x] Tester la création d'un nouvel utilisateur
## Réinitialisation de mot de passe
- [x] Créer une table password_reset_tokens dans le schéma
- [x] Ajouter les fonctions de gestion des tokens dans db.ts
- [x] Créer une procédure tRPC pour générer un token et envoyer l'email
- [x] Ajouter un bouton "Réinitialiser le mot de passe" dans AdminUsers
- [x] Créer un dialogue de confirmation pour la réinitialisation
- [x] Tester la génération de token et l'envoi d'email