@nodefony/user 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +120 -0
  3. package/dist/index.js +16 -0
  4. package/dist/nodefony/contracts/IOAuthUserProvisioner.js +1 -0
  5. package/dist/nodefony/contracts/IPasswordBlocklist.js +1 -0
  6. package/dist/nodefony/contracts/IPasswordEncoder.js +1 -0
  7. package/dist/nodefony/contracts/IPasswordVerifier.js +1 -0
  8. package/dist/nodefony/contracts/IUser.js +1 -0
  9. package/dist/nodefony/contracts/IUserProfile.js +1 -0
  10. package/dist/nodefony/contracts/IUserProvider.js +1 -0
  11. package/dist/nodefony/contracts/IUserRepository.js +1 -0
  12. package/dist/nodefony/contracts/index.js +1 -0
  13. package/dist/nodefony/errors/UserNotFoundError.js +19 -0
  14. package/dist/nodefony/errors/WeakPasswordError.js +16 -0
  15. package/dist/nodefony/service/UserService.js +280 -0
  16. package/dist/nodefony/src/AnonymousUser.js +37 -0
  17. package/dist/nodefony/src/BaseUser.js +121 -0
  18. package/dist/nodefony/src/InMemoryUserRepository.js +230 -0
  19. package/dist/nodefony/src/admin/UserAdminApi.js +588 -0
  20. package/dist/nodefony/src/encoders/Argon2idEncoder.js +107 -0
  21. package/dist/nodefony/src/encoders/BcryptEncoder.js +75 -0
  22. package/dist/nodefony/src/encoders/MigratingEncoder.js +88 -0
  23. package/dist/nodefony/src/encoders/encoderFromConfig.js +37 -0
  24. package/dist/nodefony/src/userContract.js +247 -0
  25. package/dist/nodefony/src/userFilters.js +66 -0
  26. package/dist/nodefony/src/userProfile.js +195 -0
  27. package/dist/nodefony/src/userSort.js +53 -0
  28. package/dist/nodefony/src/userStoreRegistry.js +41 -0
  29. package/dist/types/index.d.ts +40 -0
  30. package/dist/types/nodefony/contracts/IOAuthUserProvisioner.d.ts +69 -0
  31. package/dist/types/nodefony/contracts/IPasswordBlocklist.d.ts +20 -0
  32. package/dist/types/nodefony/contracts/IPasswordEncoder.d.ts +49 -0
  33. package/dist/types/nodefony/contracts/IPasswordVerifier.d.ts +26 -0
  34. package/dist/types/nodefony/contracts/IUser.d.ts +68 -0
  35. package/dist/types/nodefony/contracts/IUserProfile.d.ts +29 -0
  36. package/dist/types/nodefony/contracts/IUserProvider.d.ts +44 -0
  37. package/dist/types/nodefony/contracts/IUserRepository.d.ts +129 -0
  38. package/dist/types/nodefony/contracts/index.d.ts +7 -0
  39. package/dist/types/nodefony/errors/UserNotFoundError.d.ts +15 -0
  40. package/dist/types/nodefony/errors/WeakPasswordError.d.ts +12 -0
  41. package/dist/types/nodefony/service/UserService.d.ts +179 -0
  42. package/dist/types/nodefony/src/AnonymousUser.d.ts +27 -0
  43. package/dist/types/nodefony/src/BaseUser.d.ts +98 -0
  44. package/dist/types/nodefony/src/InMemoryUserRepository.d.ts +73 -0
  45. package/dist/types/nodefony/src/admin/UserAdminApi.d.ts +119 -0
  46. package/dist/types/nodefony/src/encoders/Argon2idEncoder.d.ts +83 -0
  47. package/dist/types/nodefony/src/encoders/BcryptEncoder.d.ts +55 -0
  48. package/dist/types/nodefony/src/encoders/MigratingEncoder.d.ts +68 -0
  49. package/dist/types/nodefony/src/encoders/encoderFromConfig.d.ts +36 -0
  50. package/dist/types/nodefony/src/userContract.d.ts +198 -0
  51. package/dist/types/nodefony/src/userFilters.d.ts +80 -0
  52. package/dist/types/nodefony/src/userProfile.d.ts +56 -0
  53. package/dist/types/nodefony/src/userSort.d.ts +41 -0
  54. package/dist/types/nodefony/src/userStoreRegistry.d.ts +15 -0
  55. package/docs/ajouter-des-champs.md +189 -0
  56. package/docs/index.md +1113 -0
  57. package/package.json +90 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,19 @@
1
+ import { nodefonyError } from "nodefony";
2
+ //#region nodefony/errors/UserNotFoundError.ts
3
+ /**
4
+ * Utilisateur introuvable dans la source d'identité — levée par les méthodes
5
+ * {@link IUserProvider} (contrat : jamais `null`, l'absence d'identité est un
6
+ * échec explicite).
7
+ *
8
+ * `code = 404` (sémantique interne). Les authenticators de `@nodefony/security`
9
+ * la convertissent en `AuthenticationError` générique : le détail (identifiant
10
+ * inconnu vs mauvais mot de passe) ne doit JAMAIS atteindre le client
11
+ * (anti-énumération de comptes) — il reste réservé aux logs/audit serveur.
12
+ */
13
+ var UserNotFoundError = class extends nodefonyError {
14
+ constructor(detail) {
15
+ super(`User not found: ${detail}`, 404);
16
+ }
17
+ };
18
+ //#endregion
19
+ export { UserNotFoundError, UserNotFoundError as default };
@@ -0,0 +1,16 @@
1
+ import { nodefonyError } from "nodefony";
2
+ //#region nodefony/errors/WeakPasswordError.ts
3
+ /**
4
+ * Mot de passe refusé par la politique (connu-compromis / interdit) — `code = 400`.
5
+ *
6
+ * Levée par `UserService.createUser`/`changePassword` quand le
7
+ * {@link IPasswordBlocklist} branché rejette le candidat. Le message reste
8
+ * générique : ni la source de la liste ni le mot de passe ne sont exposés.
9
+ */
10
+ var WeakPasswordError = class extends nodefonyError {
11
+ constructor() {
12
+ super("Password rejected by policy", 400);
13
+ }
14
+ };
15
+ //#endregion
16
+ export { WeakPasswordError, WeakPasswordError as default };
@@ -0,0 +1,280 @@
1
+ import { USER_FACETS } from "../src/userFilters.js";
2
+ import { UserNotFoundError } from "../errors/UserNotFoundError.js";
3
+ import { WeakPasswordError } from "../errors/WeakPasswordError.js";
4
+ import { profileFromClaims } from "../src/userProfile.js";
5
+ import { countFacets } from "nodefony";
6
+ import { AbstractCrudService } from "@nodefony/orm-core";
7
+ //#region nodefony/service/UserService.ts
8
+ const DUMMY_PLAINTEXT = "nodefony.dummy.timing.guard";
9
+ /**
10
+ * Service applicatif **utilisateur** — CRUD haché + authentification + events de cycle de vie.
11
+ *
12
+ * Spécialisation d'{@link AbstractCrudService} sur l'entité utilisateur : hérite du
13
+ * CRUD générique (`find`/`findOne`/`findById`/`count`/`create`/`update`/`delete` +
14
+ * events `onCreated`/`onUpdated`/`onDeleted`) et n'ajoute que le **spécifique
15
+ * credential** : hachage à la création (`createUser`), changement de mot de passe
16
+ * (`changePassword`), recherche par identifiant fonctionnel et authentification.
17
+ *
18
+ * Singleton DI stateless (cf {@link AbstractCrudService}) : aucun état par requête
19
+ * (le credential est lu depuis le repository, le hash leurre est un cache immuable).
20
+ *
21
+ * Events propres (en plus des events CRUD hérités) : `onPasswordChanged`,
22
+ * `onAuthenticated`, `onAuthenticationFailure` (avec {@link AuthFailureReason}).
23
+ *
24
+ * @typeParam — fixé : `T = IPasswordAuthenticatedUser` (le repository est la
25
+ * frontière credential), `R = IUserRepository` (conserve les finders métier).
26
+ */
27
+ var UserService = class extends AbstractCrudService {
28
+ encoder;
29
+ #dummyHash = null;
30
+ /**
31
+ * Liste de blocage des mots de passe compromis (NIST SP 800-63B §5.1.1.2) —
32
+ * hook opt-in consulté à la création/changement (jamais au login). `null`
33
+ * par défaut : le framework fournit le point d'extension, l'application
34
+ * branche sa source (top-10k, fichier, API k-anonymity).
35
+ */
36
+ passwordBlocklist = null;
37
+ /**
38
+ * @param repository - source de persistance des utilisateurs (credential inclus).
39
+ * @param encoder - encodeur de mot de passe (hash/verify/needsRehash).
40
+ * @param wiring - câblage Service ({@link ServiceWiring}) — quasi toujours omis.
41
+ */
42
+ constructor(repository, encoder, ...wiring) {
43
+ super("users", repository, ...wiring);
44
+ this.encoder = encoder;
45
+ }
46
+ /**
47
+ * Crée un utilisateur — hache le mot de passe en clair s'il est fourni, puis
48
+ * délègue au `create` générique (hooks + event `onCreated`).
49
+ *
50
+ * @param input - identité + mot de passe en clair optionnel + rôles.
51
+ * @returns l'utilisateur persisté (id généré, hash stocké).
52
+ */
53
+ async createUser(input) {
54
+ if (input.plainPassword != null) await this.#assertNotBlocked(input.plainPassword);
55
+ const password = input.plainPassword != null ? await this.encoder.hash(input.plainPassword) : null;
56
+ return this.create({
57
+ identifier: input.identifier,
58
+ roles: input.roles ?? [],
59
+ password
60
+ });
61
+ }
62
+ /**
63
+ * Charge un utilisateur par son identifiant fonctionnel (email, login...).
64
+ *
65
+ * @param identifier - identifiant unique.
66
+ * @returns l'utilisateur, ou `null`.
67
+ */
68
+ findByIdentifier(identifier) {
69
+ return this.repository.findByIdentifier(identifier);
70
+ }
71
+ /**
72
+ * Liste **paginée nativement** d'utilisateurs (filtres role/enabled/q) — délègue
73
+ * au repository, qui ne matérialise jamais plus d'une page. À préférer
74
+ * systématiquement à `find()` pour tout listing (data plane admin, écran).
75
+ *
76
+ * @param query - filtres + fenêtre de page.
77
+ * @returns une page d'utilisateurs ({@link IPage}).
78
+ */
79
+ listPage(query) {
80
+ return this.repository.listPage(query);
81
+ }
82
+ /**
83
+ * Champs de tri que le repository **actuellement branché** sait honorer.
84
+ *
85
+ * La capacité se CONSTATE au runtime plutôt que de se déduire : un adapter
86
+ * tiers qui ne trierait pas rend une liste vide, et le data plane refuse alors
87
+ * tout `?order=` (400) au lieu de servir une page dans un ordre arbitraire.
88
+ *
89
+ * @returns les champs triables, liste vide si le repository ne trie pas.
90
+ */
91
+ sortableFields() {
92
+ return this.repository.sortableFields ?? [];
93
+ }
94
+ /**
95
+ * Compte les administrateurs actifs porteurs de `adminRole` — garde-fou
96
+ * anti-lockout calculé au store (jamais en chargeant tous les utilisateurs).
97
+ *
98
+ * @param adminRole - rôle d'administration à dénombrer.
99
+ * @returns le nombre d'admins actifs.
100
+ */
101
+ countActiveAdmins(adminRole) {
102
+ return this.repository.countActiveAdmins(adminRole);
103
+ }
104
+ /**
105
+ * Les compteurs de tête de la console — posés sur l'annuaire ENTIER, pas sur
106
+ * la page affichée.
107
+ *
108
+ * Les populations se **recoupent** (un compte peut être désactivé ET
109
+ * verrouillé, un administrateur peut avoir un lien social) : chacune est
110
+ * comptée, aucune n'est déduite d'une autre.
111
+ *
112
+ * `admins` est composé ici et non déclaré dans {@link USER_FACETS} : le rôle
113
+ * d'administration est une valeur de configuration, pas une constante du
114
+ * vocabulaire — l'inscrire dans la table figerait `ROLE_NODEFONY_ADMIN` pour
115
+ * une plateforme qui peut le renommer.
116
+ *
117
+ * @param adminRole - rôle d'administration à dénombrer.
118
+ * @param query - filtres à appliquer avant comptage (sans fenêtre).
119
+ */
120
+ async countUserFacets(adminRole, query) {
121
+ const repo = this.repository;
122
+ const [facets, admins] = await Promise.all([countFacets(USER_FACETS, (facet) => repo.countUsers({
123
+ ...query,
124
+ ...facet
125
+ })), repo.countUsers({
126
+ ...query,
127
+ role: adminRole
128
+ })]);
129
+ return {
130
+ ...facets,
131
+ admins: admins >= 0 ? admins : null
132
+ };
133
+ }
134
+ /**
135
+ * Change le mot de passe d'un utilisateur — hache le clair avant persistance.
136
+ *
137
+ * Distinct du `update` générique : émet l'event credential `onPasswordChanged`,
138
+ * pas `onUpdated`.
139
+ *
140
+ * @param id - identifiant interne ciblé.
141
+ * @param plainPassword - nouveau mot de passe en clair.
142
+ * @returns l'utilisateur mis à jour, ou `null`.
143
+ */
144
+ async changePassword(id, plainPassword) {
145
+ await this.#assertNotBlocked(plainPassword);
146
+ const password = await this.encoder.hash(plainPassword);
147
+ const updated = await this.repository.updateOne({ id }, { password });
148
+ if (updated !== null) this.fire("onPasswordChanged", updated);
149
+ return updated;
150
+ }
151
+ /**
152
+ * Authentifie par identifiant + mot de passe.
153
+ *
154
+ * Vérifie l'existence, l'état du compte (actif, non verrouillé), la présence d'un
155
+ * credential local, puis le mot de passe. Au succès, re-hache de façon transparente
156
+ * si le coût stocké est obsolète ({@link IPasswordEncoder.needsRehash}). **Tous** les
157
+ * chemins d'échec (identifiant inconnu, compte verrouillé/désactivé, compte sans
158
+ * password, mauvais mot de passe) consomment exactement une opération de hachage
159
+ * (vérification réelle ou hash leurre) pour niveler le temps de réponse : le message
160
+ * 401 est uniforme, le timing doit l'être aussi (anti énumération de comptes, OWASP).
161
+ *
162
+ * @param identifier - identifiant fonctionnel saisi.
163
+ * @param plain - mot de passe en clair saisi.
164
+ * @returns l'utilisateur authentifié, ou `null` en cas d'échec. Émet
165
+ * `onAuthenticated` (succès) ou `onAuthenticationFailure` (échec + raison).
166
+ */
167
+ async authenticate(identifier, plain) {
168
+ const user = await this.repository.findByIdentifier(identifier);
169
+ if (user === null) {
170
+ await this.consumeDummy(plain);
171
+ return this.fail(identifier, "unknown_identifier");
172
+ }
173
+ if (user.isLocked()) {
174
+ await this.consumeDummy(plain);
175
+ return this.fail(identifier, "locked");
176
+ }
177
+ if (!user.isActive()) {
178
+ await this.consumeDummy(plain);
179
+ return this.fail(identifier, "disabled");
180
+ }
181
+ const hash = user.password;
182
+ if (hash === null) {
183
+ await this.consumeDummy(plain);
184
+ return this.fail(identifier, "no_password");
185
+ }
186
+ let ok = false;
187
+ try {
188
+ ok = await this.encoder.verify(plain, hash);
189
+ } catch {
190
+ ok = false;
191
+ }
192
+ if (!ok) return this.fail(identifier, "bad_credentials");
193
+ if (this.encoder.needsRehash(hash)) {
194
+ const fresh = await this.encoder.hash(plain);
195
+ const rehashed = await this.repository.updateOne({ id: user.id }, { password: fresh });
196
+ this.fire("onPasswordChanged", rehashed ?? user);
197
+ }
198
+ this.fire("onAuthenticated", user);
199
+ return user;
200
+ }
201
+ /**
202
+ * {@inheritDoc IUserProvider.loadUserByIdentifier}
203
+ */
204
+ async loadUserByIdentifier(identifier) {
205
+ const user = await this.repository.findByIdentifier(identifier);
206
+ if (user === null) throw new UserNotFoundError(`identifier "${identifier}"`);
207
+ return user;
208
+ }
209
+ /**
210
+ * {@inheritDoc IUserProvider.loadUserByOAuth}
211
+ *
212
+ * @remarks Pas de provisionnement *Shadow User* ici : la création de la ligne
213
+ * locale au premier login externe est portée par `provisionOAuthUser()`, que
214
+ * `OAuth2Service.exchangeAndProvision()` (`@nodefony/security`) appelle après
215
+ * l'échange du code — le provider, lui, ne fait que lire.
216
+ */
217
+ async loadUserByOAuth(provider, providerId) {
218
+ const user = await this.repository.findBySocialProvider(provider, providerId);
219
+ if (user === null) throw new UserNotFoundError(`social ${provider}:${providerId}`);
220
+ return user;
221
+ }
222
+ /**
223
+ * {@inheritDoc IUserProvider.refreshUser}
224
+ */
225
+ async refreshUser(user) {
226
+ const fresh = await this.findById(user.id);
227
+ if (fresh === null) throw new UserNotFoundError(`id "${user.id}" (compte supprimé)`);
228
+ return fresh;
229
+ }
230
+ /**
231
+ * {@inheritDoc IOAuthUserProvisioner.provisionOAuthUser}
232
+ *
233
+ * @remarks Implémentation **par défaut** (find-or-create) : lit le lien
234
+ * existant, sinon — si `allowSignup` — crée une ligne locale 100 % OAuth
235
+ * (`password: null`, rôles = `policy.defaultRoles`) liée au compte externe.
236
+ * **Aucune liaison automatique** à un compte local existant par email (un email
237
+ * non vérifié serait un vecteur d'usurpation, OWASP) : un compte externe non lié
238
+ * donne TOUJOURS un nouvel utilisateur. Le rattachement à un compte existant se
239
+ * fait explicitement, utilisateur connecté (hors P6).
240
+ */
241
+ async provisionOAuthUser(profile, policy) {
242
+ const existing = await this.repository.findBySocialProvider(profile.provider, profile.providerId);
243
+ if (existing !== null) return existing;
244
+ if (!policy.allowSignup) throw new UserNotFoundError(`social ${profile.provider}:${profile.providerId} (signup disabled)`);
245
+ const identifier = profile.email ?? `${profile.provider}:${profile.providerId}`;
246
+ const link = {
247
+ provider: profile.provider,
248
+ providerId: profile.providerId,
249
+ createdAt: /* @__PURE__ */ new Date()
250
+ };
251
+ const claims = { ...profile.raw };
252
+ if (profile.name) claims.name = profile.name;
253
+ if (profile.email) claims.email = profile.email;
254
+ const oauthProfile = profileFromClaims(claims);
255
+ const data = {
256
+ identifier,
257
+ roles: [...policy.defaultRoles],
258
+ password: null,
259
+ socialProviders: [link]
260
+ };
261
+ if (Object.keys(oauthProfile).length > 0) data.metadata = { profile: oauthProfile };
262
+ return this.create(data);
263
+ }
264
+ async #assertNotBlocked(plain) {
265
+ if (this.passwordBlocklist === null) return;
266
+ if (await this.passwordBlocklist.isBlocked(plain)) throw new WeakPasswordError();
267
+ }
268
+ fail(identifier, reason) {
269
+ this.fire("onAuthenticationFailure", identifier, reason);
270
+ return null;
271
+ }
272
+ async consumeDummy(plain) {
273
+ if (this.#dummyHash === null) this.#dummyHash = await this.encoder.hash(DUMMY_PLAINTEXT);
274
+ try {
275
+ await this.encoder.verify(plain, this.#dummyHash);
276
+ } catch {}
277
+ }
278
+ };
279
+ //#endregion
280
+ export { UserService };
@@ -0,0 +1,37 @@
1
+ //#region nodefony/src/AnonymousUser.ts
2
+ /** Rôle unique d'un utilisateur non authentifié. */
3
+ const ROLE_ANONYMOUS = "ROLE_ANONYMOUS";
4
+ const ANONYMOUS_ROLES = [ROLE_ANONYMOUS];
5
+ Object.freeze(ANONYMOUS_ROLES);
6
+ /**
7
+ * Utilisateur **non authentifié** — implémente {@link IUser} sans credential.
8
+ *
9
+ * Permet de typer le contexte de sécurité sans `null` (Zero Trust : un visiteur
10
+ * est un utilisateur anonyme, pas une absence d'utilisateur). `@CurrentUser`
11
+ * retourne `IUser | AnonymousUser`, jamais `null`. Sans état mutable : un
12
+ * {@link anonymousUser} singleton est réutilisé pour éviter toute allocation par
13
+ * requête.
14
+ */
15
+ var AnonymousUser = class {
16
+ id = "anonymous";
17
+ identifier = "anon.";
18
+ roles = ANONYMOUS_ROLES;
19
+ hasRole(role) {
20
+ return role === ROLE_ANONYMOUS;
21
+ }
22
+ /** Un anonyme est utilisable (non désactivé). @returns `true`. */
23
+ isActive() {
24
+ return true;
25
+ }
26
+ /** Un anonyme n'est jamais verrouillé. @returns `false`. */
27
+ isLocked() {
28
+ return false;
29
+ }
30
+ };
31
+ /**
32
+ * Singleton d'{@link AnonymousUser} — instance partagée et gelée à réutiliser à
33
+ * chaque requête non authentifiée (zéro allocation dans le hot path).
34
+ */
35
+ const anonymousUser = Object.freeze(new AnonymousUser());
36
+ //#endregion
37
+ export { AnonymousUser, ROLE_ANONYMOUS, anonymousUser };
@@ -0,0 +1,121 @@
1
+ //#region nodefony/src/BaseUser.ts
2
+ /**
3
+ * Implémentation POJO de référence d'un utilisateur — base partagée par tous les ORM.
4
+ *
5
+ * Porte le contrat {@link IPasswordAuthenticatedUser} plus les **champs
6
+ * anti-migration** : `socialProviders` (JSON, pas de colonnes par fournisseur),
7
+ * `metadata` (extras libres typés `Record<string, unknown>`, jamais `any`),
8
+ * `currentRole` (profil actif de session). Les entités persistées des adapters
9
+ * (`MongooseUser`, `DrizzleUser`...) étendent cette classe ; Drizzle la mappe.
10
+ *
11
+ * Hors hot path requête (instanciée à l'authentification, pas par requête) : les
12
+ * allocations de `roles`/`socialProviders`/`metadata` y sont acceptables. Pour un
13
+ * utilisateur anonyme (créé par requête non authentifiée), préférer
14
+ * {@link AnonymousUser} et son singleton.
15
+ */
16
+ var BaseUser = class {
17
+ id;
18
+ identifier;
19
+ roles;
20
+ password;
21
+ /** Profil de rôle actif en session (P5.11) — distinct des rôles plats. */
22
+ currentRole;
23
+ socialProviders;
24
+ metadata;
25
+ enabled;
26
+ locked;
27
+ constructor(options) {
28
+ this.id = options.id;
29
+ this.identifier = options.identifier;
30
+ this.roles = options.roles ? [...options.roles] : [];
31
+ this.password = options.password ?? null;
32
+ this.enabled = options.enabled ?? true;
33
+ this.locked = options.locked ?? false;
34
+ this.currentRole = options.currentRole ?? null;
35
+ this.socialProviders = options.socialProviders ? [...options.socialProviders] : [];
36
+ this.metadata = options.metadata ?? {};
37
+ }
38
+ hasRole(role) {
39
+ return this.roles.includes(role);
40
+ }
41
+ isActive() {
42
+ return this.enabled;
43
+ }
44
+ isLocked() {
45
+ return this.locked;
46
+ }
47
+ /**
48
+ * Ajoute un rôle s'il n'est pas déjà présent (idempotent).
49
+ *
50
+ * @param role - rôle à accorder.
51
+ * @returns `this` (chaînable).
52
+ */
53
+ addRole(role) {
54
+ if (!this.roles.includes(role)) this.roles.push(role);
55
+ return this;
56
+ }
57
+ /**
58
+ * Retire un rôle s'il est présent (idempotent).
59
+ *
60
+ * @param role - rôle à révoquer.
61
+ * @returns `this` (chaînable).
62
+ */
63
+ removeRole(role) {
64
+ const i = this.roles.indexOf(role);
65
+ if (i !== -1) this.roles.splice(i, 1);
66
+ return this;
67
+ }
68
+ /**
69
+ * Lie un compte externe (OAuth/OIDC) — pattern Shadow User. Idempotent sur la
70
+ * paire `(provider, providerId)`.
71
+ *
72
+ * @param link - référence du compte externe.
73
+ * @returns `this` (chaînable).
74
+ */
75
+ addSocialProvider(link) {
76
+ if (!this.socialProviders.some((p) => p.provider === link.provider && p.providerId === link.providerId)) this.socialProviders.push(link);
77
+ return this;
78
+ }
79
+ /** Active le compte. @returns `this`. */
80
+ enable() {
81
+ this.enabled = true;
82
+ return this;
83
+ }
84
+ /** Désactive le compte. @returns `this`. */
85
+ disable() {
86
+ this.enabled = false;
87
+ return this;
88
+ }
89
+ /** Verrouille le compte. @returns `this`. */
90
+ lock() {
91
+ this.locked = true;
92
+ return this;
93
+ }
94
+ /** Déverrouille le compte. @returns `this`. */
95
+ unlock() {
96
+ this.locked = false;
97
+ return this;
98
+ }
99
+ /**
100
+ * Définit le profil de rôle actif (session). N'altère pas {@link roles}.
101
+ *
102
+ * @param role - rôle actif, ou `null` pour réinitialiser.
103
+ * @returns `this`.
104
+ */
105
+ setCurrentRole(role) {
106
+ this.currentRole = role;
107
+ return this;
108
+ }
109
+ /**
110
+ * Remplace le hash de mot de passe stocké.
111
+ *
112
+ * @param hash - nouveau hash (déjà produit par un {@link IPasswordEncoder}), ou `null`.
113
+ * @returns `this`.
114
+ */
115
+ setPassword(hash) {
116
+ this.password = hash;
117
+ return this;
118
+ }
119
+ };
120
+ //#endregion
121
+ export { BaseUser };