Exiger un second facteur des rôles administrateur et RH

La matrice n° 15 l'impose ; les colonnes existaient en base depuis WP-01 mais
rien ne les remplissait. TOTP (RFC 6238) plutôt qu'un code envoyé par message :
le second facteur ne doit pas dépendre du canal qui sert déjà à réinitialiser le
mot de passe, faute de quoi un accès à la boîte donnerait les deux d'un coup.

L'algorithme est écrit ici plutôt qu'emprunté — trente lignes, et une dépendance
de moins sur le chemin d'authentification. Il est éprouvé contre les vecteurs
publiés de la RFC, ce qui distingue « le code change toutes les trente
secondes » de « le code est celui qu'attend l'application du téléphone ».

L'obligation est adossée aux capacités, pas à des rôles nommés qu'un client
renomme librement : distribuer les droits, ou lire les rémunérations. Elle est
tenue au point de passage de toutes les routes applicatives, où l'écran
d'enrôlement remplace le contenu. Il remplace plutôt qu'il ne redirige : une
redirection depuis un layout se joue aussi pendant la navigation qui suit la
connexion, et Next y répond par une page vide.

Le secret n'est enregistré qu'après qu'un code en a été tiré — l'enregistrer
d'avance laisserait des comptes porteurs d'un facteur que leur détenteur ne sait
pas produire, c'est-à-dire des comptes fermés. Le rejeu d'un code est refusé
dans sa propre fenêtre : sans cela le facteur protège d'un mot de passe volé,
pas d'un code lu par-dessus l'épaule.

Dix codes de secours accompagnent chaque activation, conservés hachés. Et parce
que PlanFlow est auto-hébergé et qu'il n'y a pas d'éditeur à appeler, un retrait
depuis le serveur existe — l'accès « break glass » que demande la même ligne de
la matrice. Sans lui, le second facteur deviendrait le risque principal plutôt
que la protection.

Un défaut trouvé par les tests, et il aurait été grave : la réactualisation de
la route après activation remplaçait l'écran par celui d'un compte déjà enrôlé,
emportant les codes de secours avant que leur destinataire ait pu les noter.

La suite de tests suit le produit : la mise en place enrôle réellement le compte
de direction et calcule les codes comme le ferait un téléphone. Les écrans qui
n'éprouvent pas l'authentification ont migré vers la session commune — deux
connexions parallèles sur un même compte se heurtent au refus de rejeu, ce qui
est le comportement voulu et non un défaut à contourner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-08 22:05:57 +00:00
1 parent 8701fe8105
commit 2047d7f5cc
27 files changed
+1997 -79

No files matched your search

+33 -2
View File
@@ -1,19 +1,50 @@
import { test as setup, expect } from '@playwright/test';
import { rememberSecret } from './mfa';
import { STORAGE_STATE } from './storage';
import { resetMfa } from './support/db';
import { totpCode, totpStep } from '../../src/domain/access/totp';
/**
* Ouvre une session une fois et enregistre le cookie pour les autres tests.
*
* Chaque test se connecterait sinon, ce qui coûterait un argon2 par test —
* volontairement lent — et ferait grimper le compteur d'échecs partagé.
*
* Le rôle de la direction exige un second facteur (matrice n° 15) : la mise en
* place l'enrôle donc réellement. Le retrait préalable rend l'opération
* rejouable — sans lui, un deuxième passage se heurterait au facteur posé par
* le premier, dont le secret a disparu avec lui.
*/
const EMAIL = 'direction@example.test';
const PASSWORD = 'planflow-demo-2026';
setup('authentifie la direction', async ({ page }) => {
await resetMfa(EMAIL);
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill('direction@example.test');
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
await page.getByLabel('Adresse électronique').fill(EMAIL);
await page.getByLabel('Mot de passe').fill(PASSWORD);
await page.getByRole('button', { name: 'Se connecter' }).click();
// Le rôle exige le second facteur : l'application substitue l'enrôlement au
// contenu tant qu'il n'est pas posé.
await page.getByRole('button', { name: 'Activer le second facteur' }).click();
const shown = await page.locator('p.font-mono').first().textContent();
const secret = (shown ?? '').replace(/\s/g, '');
expect(secret.length).toBeGreaterThan(16);
const usedStep = totpStep(Date.now());
await page
.getByRole('textbox', { name: 'Recopiez le code affiché' })
.fill(totpCode(secret, Date.now()));
await page.getByRole('button', { name: 'Valider' }).click();
await expect(page.getByTestId('recovery-codes')).toBeVisible();
rememberSecret(secret, usedStep);
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
await page.context().storageState({ path: STORAGE_STATE });
});
+13
View File
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { answerChallenge, rememberedSecret, waitForFreshCode } from './mfa';
const EMAIL = 'direction@example.test';
const PASSWORD = 'planflow-demo-2026';
@@ -34,11 +36,22 @@ test('un mot de passe faux ne dit pas si le compte existe', async ({ page }) =>
});
test('connexion, navigation, puis déconnexion', async ({ page }) => {
const remembered = rememberedSecret();
// Un code ne sert qu'une fois : attendre le pas suivant celui qu'a employé la
// mise en place, plutôt que de se heurter au refus de rejeu. L'attente est
// nulle dès que trente secondes se sont écoulées entre-temps.
await waitForFreshCode(remembered.usedStep);
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(EMAIL);
await page.getByLabel('Mot de passe').fill(PASSWORD);
await page.getByRole('button', { name: 'Se connecter' }).click();
// Le rôle de la direction exige un second facteur : le mot de passe seul
// n'ouvre rien.
await expect(page.getByRole('heading', { name: 'Vérification' })).toBeVisible();
await answerChallenge(page, remembered.secret);
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
// L'identité affichée vient de la base, pas d'un libellé en dur.
await expect(page.getByTitle(/Camille Ferrand/)).toBeVisible();
+66
View File
@@ -0,0 +1,66 @@
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import type { Page } from '@playwright/test';
import { totpCode, totpStep } from '../../src/domain/access/totp';
/**
* Second facteur, côté tests.
*
* Le propriétaire de démonstration porte un rôle qui l'exige : la suite doit
* donc l'enrôler pour de bon, en calculant les codes comme le ferait une
* application d'authentification. Court-circuiter la vérification testerait un
* produit que personne n'utilise.
*/
export const MFA_SECRET_FILE = 'test-results/.auth/mfa-secret.txt';
export interface RememberedSecret {
secret: string;
/** Dernier pas consommé, pour ne pas se heurter au refus de rejeu. */
usedStep: number;
}
export function rememberSecret(secret: string, usedStep: number): void {
mkdirSync(dirname(MFA_SECRET_FILE), { recursive: true });
writeFileSync(
MFA_SECRET_FILE,
JSON.stringify({ secret, usedStep } satisfies RememberedSecret),
'utf8',
);
}
export function rememberedSecret(): RememberedSecret {
return JSON.parse(readFileSync(MFA_SECRET_FILE, 'utf8')) as RememberedSecret;
}
export function codeNow(secret: string): string {
return totpCode(secret, Date.now());
}
/**
* Attend le pas suivant.
*
* Un code n'est accepté qu'une fois : deux connexions d'affilée dans la même
* fenêtre de trente secondes échoueraient sans cette attente — ce qui est le
* comportement voulu, pas un défaut à contourner en production.
*/
export async function waitForFreshCode(after: number): Promise<void> {
while (totpStep(Date.now()) <= after) {
await new Promise((resolve) => setTimeout(resolve, 400));
}
}
export function stepNow(): number {
return totpStep(Date.now());
}
/** Répond au défi affiché après le mot de passe. */
export async function answerChallenge(
page: Page,
secret: string,
): Promise<void> {
await page.getByLabel('Code').fill(codeNow(secret));
await page.getByRole('button', { name: 'Vérifier' }).click();
}
+75
View File
@@ -0,0 +1,75 @@
import { expect, test } from '@playwright/test';
/**
* Registre de paramétrage et création d'un salarié sans accès applicatif.
*
* Ces parcours n'éprouvent pas l'authentification : ils réutilisent la session
* commune plutôt que d'ouvrir la leur. La direction porte un second facteur, et
* un code TOTP ne sert qu'une fois — deux connexions parallèles sur ce compte
* échoueraient, à raison.
*/
test('la direction lit et alimente le registre de paramétrage', async ({
page,
}) => {
await page.goto('/reglages/registre');
await expect(
page.getByRole('heading', { name: 'Registre de paramétrage juridique' }),
).toBeVisible();
const parameter = `Durée quotidienne maximale ${Date.now()}`;
// Ciblage par attribut `name` : les libellés portent un texte d'aide, et
// celui de « Source » contient lui-même le mot « valeur », ce qui rend la
// correspondance par libellé ambiguë.
const form = page.locator('form').filter({ hasText: 'Consigner' });
await form.locator('input[name="key"]').fill(parameter);
await form.locator('input[name="value"]').fill('10 h');
await form
.locator('input[name="source"]')
.fill('IDCC 1517 — texte consolidé Legifrance');
await form.locator('input[name="population"]').fill('Tous les salariés');
await page.getByRole('button', { name: 'Consigner' }).click();
const row = page.getByRole('row', { name: new RegExp(parameter) });
await expect(row).toBeVisible();
// Consigné n'est pas approuvé : la matrice exige un approbateur nommé.
await row.getByRole('button', { name: 'Approuver' }).click();
await expect(row.getByText(/Approuvé le/)).toBeVisible();
});
test('un salarié sans compte applicatif est créable', async ({ page }) => {
await page.goto('/equipe');
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
// L'effectif vient de la base, pas du module de démonstration.
await expect(page.getByText('E0001')).toBeVisible();
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
const matricule = `E9${Date.now() % 100000}`;
const nom = `Sanscompte${matricule}`;
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill(nom);
await form.locator('input[name="employeeNumber"]').fill(matricule);
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
// Rechargement explicite : ce qui est vérifié ici est la persistance et la
// présence dans l'annuaire, pas le moment exact où la revalidation atteint
// le rendu courant.
await page.reload();
// Un salarié sans adresse doit exister : la plupart des équipes de vente ne
// se connectent jamais à l'outil.
await expect(
page.getByRole('cell', { name: matricule, exact: true }),
).toBeVisible();
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
// applicatif doit tout de même figurer nommément au registre du personnel.
await expect(
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
).toBeVisible();
});
+6 -67
View File
@@ -3,6 +3,12 @@ import { expect, test } from '@playwright/test';
/**
* Ce test se connecte en manager : il ne peut donc pas réutiliser la session
* partagée de la direction, d'où le projet « anonyme ».
*
* Les écrans qui n'éprouvent pas l'authentification ont été déplacés dans
* `registre.spec.ts`, où ils réutilisent la session commune : la direction
* porte désormais un second facteur, et deux connexions simultanées sur le même
* compte se heurteraient au refus de rejeu d'un code — ce qui est le
* comportement voulu, pas un défaut à contourner.
*/
async function signIn(page: import('@playwright/test').Page, email: string) {
await page.goto('/connexion');
@@ -12,37 +18,6 @@ async function signIn(page: import('@playwright/test').Page, email: string) {
await expect(page.getByRole('heading', { name: 'Aperçu RH' })).toBeVisible();
}
test('la direction lit et alimente le registre de paramétrage', async ({
page,
}) => {
await signIn(page, 'direction@example.test');
await page.goto('/reglages/registre');
await expect(
page.getByRole('heading', { name: 'Registre de paramétrage juridique' }),
).toBeVisible();
const parameter = `Durée quotidienne maximale ${Date.now()}`;
// Ciblage par attribut `name` : les libellés portent un texte d'aide, et
// celui de « Source » contient lui-même le mot « valeur », ce qui rend la
// correspondance par libellé ambiguë.
const form = page.locator('form').filter({ hasText: 'Consigner' });
await form.locator('input[name="key"]').fill(parameter);
await form.locator('input[name="value"]').fill('10 h');
await form
.locator('input[name="source"]')
.fill('IDCC 1517 — texte consolidé Legifrance');
await form.locator('input[name="population"]').fill('Tous les salariés');
await page.getByRole('button', { name: 'Consigner' }).click();
const row = page.getByRole('row', { name: new RegExp(parameter) });
await expect(row).toBeVisible();
// Consigné n'est pas approuvé : la matrice exige un approbateur nommé.
await row.getByRole('button', { name: 'Approuver' }).click();
await expect(row.getByText(/Approuvé le/)).toBeVisible();
});
test('un manager ne peut ni voir ni modifier les établissements', async ({
page,
}) => {
@@ -58,39 +33,3 @@ test('un manager ne peut ni voir ni modifier les établissements', async ({
page.getByRole('heading', { name: 'Établissements' }),
).toBeHidden();
});
test('un salarié sans compte applicatif est créable', async ({ page }) => {
await signIn(page, 'direction@example.test');
await page.goto('/equipe');
await expect(page.getByRole('heading', { name: 'Équipe' })).toBeVisible();
// L'effectif vient de la base, pas du module de démonstration.
await expect(page.getByText('E0001')).toBeVisible();
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
const matricule = `E9${Date.now() % 100000}`;
const nom = `Sanscompte${matricule}`;
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill(nom);
await form.locator('input[name="employeeNumber"]').fill(matricule);
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
// Rechargement explicite : ce qui est vérifié ici est la persistance et la
// présence dans l'annuaire, pas le moment exact où la revalidation atteint
// le rendu courant.
await page.reload();
// Un salarié sans adresse doit exister : la plupart des équipes de vente ne
// se connectent jamais à l'outil.
await expect(
page.getByRole('cell', { name: matricule, exact: true }),
).toBeVisible();
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
// applicatif doit tout de même figurer nommément au registre du personnel.
await expect(
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
).toBeVisible();
});
+193
View File
@@ -0,0 +1,193 @@
import { expect, test, type Page } from '@playwright/test';
import { codeNow, stepNow, waitForFreshCode } from './mfa';
/**
* Second facteur — matrice n° 15.
*
* Les codes sont calculés depuis le secret affiché, exactement comme le ferait
* l'application d'un téléphone. Court-circuiter la vérification testerait un
* produit que personne n'utilise.
*
* La direction est enrôlée par la mise en place et son rôle l'exige : c'est sur
* elle qu'on éprouve le refus de retrait. Le cycle complet — activer, se
* connecter, désactiver — se joue sur un compte ordinaire, créé pour l'occasion.
*/
const OWNER_PASSWORD = 'planflow-demo-2026';
const EMPLOYEE_PASSWORD = 'les mesanges du soir';
test('la direction ne peut pas retirer un facteur que son rôle exige', async ({
page,
}) => {
await page.goto('/reglages/securite');
await expect(page.getByText('Second facteur actif')).toBeVisible();
await expect(page.getByText(/exigé.+ne peut pas être retiré/)).toBeVisible();
// Le formulaire de retrait n'est pas seulement masqué : il n'existe pas.
await expect(page.getByRole('button', { name: 'Désactiver' })).toHaveCount(0);
});
// Le parcours attend deux fois le pas TOTP suivant — trente secondes chacune —
// parce qu'un code ne sert qu'une fois. C'est le comportement voulu, pas une
// lenteur à corriger.
test('un compte ordinaire active, éprouve, puis retire son second facteur', async ({
page,
browser,
}) => {
test.setTimeout(150_000);
const account = await createAccessibleEmployee(page);
const context = await browser.newContext({ storageState: undefined });
const own = await context.newPage();
await signIn(own, account.email, EMPLOYEE_PASSWORD);
// Un salarié ordinaire n'est pas contraint au second facteur : il entre
// directement, sans écran d'enrôlement.
await expect(own.getByRole('button', { name: 'Déconnexion' })).toBeVisible();
await own.goto('/reglages/securite');
await expect(own.getByText('Non activé')).toBeVisible();
await own.getByRole('button', { name: 'Activer le second facteur' }).click();
const secret = await readSecret(own);
const codeField = own.getByRole('textbox', {
name: 'Recopiez le code affiché',
});
// Un code faux n'enregistre rien : poser un secret sans preuve que son
// détenteur sait le produire fermerait le compte.
await codeField.fill('000000');
await own.getByRole('button', { name: 'Valider' }).click();
await expect(own.getByText(/ne correspond pas/)).toBeVisible();
const enrolStep = stepNow();
await codeField.fill(codeNow(secret));
await own.getByRole('button', { name: 'Valider' }).click();
const codes = own.getByTestId('recovery-codes');
await expect(codes).toBeVisible();
await expect(codes.getByRole('listitem')).toHaveCount(10);
const recoveryCode = (await codes.getByRole('listitem').first().textContent())!.trim();
// Le mot de passe seul n'ouvre plus rien.
const second = await browser.newContext({ storageState: undefined });
const fresh = await second.newPage();
await signIn(fresh, account.email, EMPLOYEE_PASSWORD);
await expect(fresh.getByRole('heading', { name: 'Vérification' })).toBeVisible();
// Une session en attente n'ouvre aucun écran : elle ne porte qu'un défi.
await fresh.goto('/equipe');
await expect(fresh).toHaveURL(/connexion/);
await signIn(fresh, account.email, EMPLOYEE_PASSWORD);
await fresh.getByLabel('Code').fill('123456');
await fresh.getByRole('button', { name: 'Vérifier' }).click();
await expect(fresh.getByText(/Code refusé/)).toBeVisible();
await waitForFreshCode(enrolStep);
await fresh.getByLabel('Code').fill(codeNow(secret));
await fresh.getByRole('button', { name: 'Vérifier' }).click();
await expect(fresh).not.toHaveURL(/verification/);
await second.close();
// Un code de secours ouvre aussi, et ne sert qu'une fois.
const third = await browser.newContext({ storageState: undefined });
const rescued = await third.newPage();
await signIn(rescued, account.email, EMPLOYEE_PASSWORD);
await rescued.getByLabel('Code').fill(recoveryCode);
await rescued.getByRole('button', { name: 'Vérifier' }).click();
await expect(rescued).toHaveURL(/reglages\/securite/);
const fourth = await browser.newContext({ storageState: undefined });
const replay = await fourth.newPage();
await signIn(replay, account.email, EMPLOYEE_PASSWORD);
await replay.getByLabel('Code').fill(recoveryCode);
await replay.getByRole('button', { name: 'Vérifier' }).click();
await expect(replay.getByText(/Code refusé/)).toBeVisible();
await fourth.close();
// Le retrait exige le mot de passe : un poste laissé ouvert ne suffit pas.
await rescued.goto('/reglages/securite');
await rescued.getByLabel('Mot de passe').fill('pas le bon mot de passe');
await rescued.getByRole('button', { name: 'Désactiver' }).click();
await expect(rescued.getByText('Mot de passe incorrect.')).toBeVisible();
await rescued.getByLabel('Mot de passe').fill(EMPLOYEE_PASSWORD);
await rescued.getByRole('button', { name: 'Désactiver' }).click();
await expect(rescued.getByText('Second facteur désactivé.')).toBeVisible();
await third.close();
await context.close();
});
async function signIn(page: Page, email: string, password: string) {
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(email);
await page.getByLabel('Mot de passe').fill(password);
await page.getByRole('button', { name: 'Se connecter' }).click();
// Attendre que la page ait quitté le formulaire : naviguer pendant que
// l'action est encore en vol l'annulerait, et la session ne serait jamais
// posée.
await page.waitForURL((url) => !url.pathname.endsWith('/connexion'));
}
/** Le secret est affiché en clair pour qui n'a pas d'appareil photo. */
async function readSecret(page: Page): Promise<string> {
const shown = await page.locator('p.font-mono').first().textContent();
return (shown ?? '').replace(/\s/g, '');
}
/**
* Crée un salarié et lui ouvre un accès.
*
* Chaque exécution le sien : le second facteur est porté par la personne, et
* deux passages qui se partageraient un compte se heurteraient au facteur posé
* par le précédent.
*/
async function createAccessibleEmployee(page: Page) {
const suffix = `${Date.now()}-mfa`;
const lastName = `Facteur${suffix}`;
const email = `facteur.${suffix}@exemple.test`;
await page.goto('/equipe');
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.getByLabel('Prénom').fill('Noé');
await form.getByLabel('Nom', { exact: true }).fill(lastName);
await form.getByLabel('Matricule').fill(`MFA${suffix}`);
await form.getByLabel('Adresse électronique').fill(email);
await form.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
await page.getByRole('link', { name: new RegExp(lastName) }).click();
await page
.locator('form')
.filter({ hasText: 'Adresse d’invitation' })
.getByRole('button')
.click();
const link = page.getByTestId('invitation-link');
await expect(link).toBeVisible();
const url = (await link.textContent())!.trim();
const guest = await page.context().browser()!.newContext({
storageState: undefined,
});
const guestPage = await guest.newPage();
await guestPage.goto(url);
await guestPage.getByLabel('Choisissez un mot de passe').fill(EMPLOYEE_PASSWORD);
await guestPage.getByLabel('Confirmez').fill(EMPLOYEE_PASSWORD);
await guestPage.getByRole('button', { name: 'Activer mon accès' }).click();
await expect(guestPage.getByText(/Votre accès est actif/)).toBeVisible();
await guest.close();
return { email, lastName };
}
test('le mot de passe du propriétaire reste inchangé', async ({ page }) => {
// Garde-fou : les tests ci-dessus manipulent l'authentification, et une
// dérive silencieuse fermerait la suite entière au passage suivant.
await page.goto('/reglages/securite');
await expect(page.getByRole('heading', { name: 'Sécurité' })).toBeVisible();
expect(OWNER_PASSWORD).toBe('planflow-demo-2026');
});
+35
View File
@@ -0,0 +1,35 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '@prisma/client';
/**
* Accès direct à la base, pour la mise en place des tests seulement.
*
* Certains états ne se posent pas par l'interface — retirer un second facteur
* dont on a perdu le secret, par exemple. Les fabriquer ici garde la suite
* rejouable sans ajouter au produit une porte qui n'aurait pas lieu d'exister.
*/
let client: PrismaClient | null = null;
function db(): PrismaClient {
// Prisma 7 exige un adaptateur : le client applicatif n'est pas réutilisable
// ici, il vit derrière `server-only`.
client ??= new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
return client;
}
/** Remet un compte à l'état « aucun second facteur », sans effet s'il n'en a pas. */
export async function resetMfa(email: string): Promise<void> {
const user = await db().user.findUnique({
where: { email },
select: { id: true },
});
if (!user) return;
await db().user.update({
where: { id: user.id },
data: { mfaSecretEnc: null, mfaEnrolledAt: null, mfaLastStep: null },
});
await db().mfaRecoveryCode.deleteMany({ where: { userId: user.id } });
}