@hed-hog/core 0.0.276 → 0.0.278

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 (59) hide show
  1. package/dist/auth/auth.controller.d.ts +8 -1
  2. package/dist/auth/auth.controller.d.ts.map +1 -1
  3. package/dist/auth/auth.controller.js +7 -7
  4. package/dist/auth/auth.controller.js.map +1 -1
  5. package/dist/auth/auth.service.d.ts +10 -1
  6. package/dist/auth/auth.service.d.ts.map +1 -1
  7. package/dist/auth/auth.service.js +34 -8
  8. package/dist/auth/auth.service.js.map +1 -1
  9. package/dist/profile/profile.service.js +1 -1
  10. package/dist/profile/profile.service.js.map +1 -1
  11. package/dist/role/guards/role.guard.d.ts +1 -0
  12. package/dist/role/guards/role.guard.d.ts.map +1 -1
  13. package/dist/role/guards/role.guard.js +18 -0
  14. package/dist/role/guards/role.guard.js.map +1 -1
  15. package/dist/session/session.service.js +1 -1
  16. package/dist/session/session.service.js.map +1 -1
  17. package/dist/user/dto/reset-password.dto.d.ts +4 -0
  18. package/dist/user/dto/reset-password.dto.d.ts.map +1 -0
  19. package/dist/user/dto/reset-password.dto.js +26 -0
  20. package/dist/user/dto/reset-password.dto.js.map +1 -0
  21. package/dist/user/user.controller.d.ts +5 -0
  22. package/dist/user/user.controller.d.ts.map +1 -1
  23. package/dist/user/user.controller.js +13 -0
  24. package/dist/user/user.controller.js.map +1 -1
  25. package/dist/user/user.service.d.ts +6 -0
  26. package/dist/user/user.service.d.ts.map +1 -1
  27. package/dist/user/user.service.js +65 -0
  28. package/dist/user/user.service.js.map +1 -1
  29. package/hedhog/data/dashboard_component.yaml +77 -33
  30. package/hedhog/data/dashboard_component_role.yaml +132 -66
  31. package/hedhog/data/dashboard_item.yaml +100 -100
  32. package/hedhog/data/dashboard_role.yaml +18 -12
  33. package/hedhog/data/menu.yaml +6 -0
  34. package/hedhog/data/route.yaml +57 -1
  35. package/hedhog/frontend/app/account/components/change-password-form.tsx.ejs +2 -1
  36. package/hedhog/frontend/app/dashboard/components/widgets/account-security.tsx.ejs +24 -24
  37. package/hedhog/frontend/app/dashboard/components/widgets/activity-timeline.tsx.ejs +4 -4
  38. package/hedhog/frontend/app/dashboard/components/widgets/email-notifications.tsx.ejs +23 -19
  39. package/hedhog/frontend/app/dashboard/components/widgets/login-history-chart.tsx.ejs +15 -14
  40. package/hedhog/frontend/app/dashboard/components/widgets/permissions-chart.tsx.ejs +62 -58
  41. package/hedhog/frontend/app/dashboard/components/widgets/stat-access-level.tsx.ejs +18 -18
  42. package/hedhog/frontend/app/dashboard/components/widgets/stat-actions-today.tsx.ejs +18 -18
  43. package/hedhog/frontend/app/dashboard/components/widgets/stat-consecutive-days.tsx.ejs +18 -18
  44. package/hedhog/frontend/app/dashboard/components/widgets/stat-online-time.tsx.ejs +18 -18
  45. package/hedhog/frontend/app/dashboard/components/widgets/user-roles.tsx.ejs +3 -3
  46. package/hedhog/frontend/app/dashboard/components/widgets/user-sessions.tsx.ejs +34 -33
  47. package/hedhog/frontend/app/dashboard/page.tsx.ejs +29 -14
  48. package/hedhog/frontend/app/users/page.tsx.ejs +322 -1
  49. package/hedhog/frontend/messages/en.json +19 -1
  50. package/hedhog/frontend/messages/pt.json +19 -1
  51. package/package.json +4 -4
  52. package/src/auth/auth.controller.ts +21 -20
  53. package/src/auth/auth.service.ts +63 -15
  54. package/src/profile/profile.service.ts +1 -1
  55. package/src/role/guards/role.guard.ts +36 -7
  56. package/src/session/session.service.ts +2 -2
  57. package/src/user/dto/reset-password.dto.ts +11 -0
  58. package/src/user/user.controller.ts +24 -14
  59. package/src/user/user.service.ts +84 -0
@@ -208,7 +208,24 @@
208
208
  "roleRemoved": "Role removed successfully",
209
209
  "errorLoadingRoles": "Error loading roles",
210
210
  "errorAssigningRole": "Error assigning role",
211
- "errorRemovingRole": "Error removing role"
211
+ "errorRemovingRole": "Error removing role",
212
+ "passwordResetTitle": "Password Reset",
213
+ "passwordResetDescription": "Generate a new password for this user.",
214
+ "passwordResetNotice": "After this reset, the user must change the password on the next login.",
215
+ "buttonResetPassword": "Reset Password",
216
+ "passwordResetDialogTitle": "Reset User Password",
217
+ "passwordResetDialogDescription": "A strong password is generated by default. You can edit it before confirming.",
218
+ "passwordResetFieldLabel": "New temporary password",
219
+ "buttonRegeneratePassword": "Regenerate password",
220
+ "passwordResetConfirm": "Confirm reset",
221
+ "passwordResetSubmitting": "Resetting...",
222
+ "passwordResetSuccess": "Password reset successfully.",
223
+ "passwordResultTitle": "Password Generated",
224
+ "passwordResultDescription": "Copy and share this password now. It will only be shown once.",
225
+ "buttonCopyPassword": "Copy password",
226
+ "passwordCopied": "Password copied to clipboard.",
227
+ "passwordCopyError": "Unable to copy password.",
228
+ "close": "Close"
212
229
  },
213
230
  "RolePage": {
214
231
  "title": "Roles Management",
@@ -401,6 +418,7 @@
401
418
  "passwordRequired": "Password is required",
402
419
  "passwordMinLength": "Password must be at least 6 characters long",
403
420
  "mfaVerificationMessage": "Enter the verification code",
421
+ "passwordResetRequiredMessage": "You must change your password before continuing.",
404
422
  "hidePassword": "Hide password",
405
423
  "showPassword": "Show password",
406
424
  "loginWith": "Sign in with"
@@ -208,7 +208,24 @@
208
208
  "roleRemoved": "Cargo removido com sucesso",
209
209
  "errorLoadingRoles": "Erro ao carregar cargos",
210
210
  "errorAssigningRole": "Erro ao atribuir cargo",
211
- "errorRemovingRole": "Erro ao remover cargo"
211
+ "errorRemovingRole": "Erro ao remover cargo",
212
+ "passwordResetTitle": "Redefinição de senha",
213
+ "passwordResetDescription": "Gere uma nova senha para este usuário.",
214
+ "passwordResetNotice": "Após essa redefinição, o usuário será obrigado a alterar a senha no próximo login.",
215
+ "buttonResetPassword": "Redefinir senha",
216
+ "passwordResetDialogTitle": "Redefinir senha do usuário",
217
+ "passwordResetDialogDescription": "Uma senha forte é gerada por padrão. Você pode editar antes de confirmar.",
218
+ "passwordResetFieldLabel": "Nova senha temporária",
219
+ "buttonRegeneratePassword": "Gerar outra senha",
220
+ "passwordResetConfirm": "Confirmar redefinição",
221
+ "passwordResetSubmitting": "Redefinindo...",
222
+ "passwordResetSuccess": "Senha redefinida com sucesso.",
223
+ "passwordResultTitle": "Senha gerada",
224
+ "passwordResultDescription": "Copie e compartilhe essa senha agora. Ela será exibida apenas uma vez.",
225
+ "buttonCopyPassword": "Copiar senha",
226
+ "passwordCopied": "Senha copiada para a área de transferência.",
227
+ "passwordCopyError": "Não foi possível copiar a senha.",
228
+ "close": "Fechar"
212
229
  },
213
230
  "RolePage": {
214
231
  "title": "Gerenciamento de Cargos",
@@ -401,6 +418,7 @@
401
418
  "passwordRequired": "Senha obrigatória",
402
419
  "passwordMinLength": "Senha mínima de 6 caracteres",
403
420
  "mfaVerificationMessage": "Digite o código de verificação",
421
+ "passwordResetRequiredMessage": "Você deve alterar sua senha antes de continuar.",
404
422
  "hidePassword": "Ocultar senha",
405
423
  "showPassword": "Mostrar senha",
406
424
  "loginWith": "Entrar com"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/core",
3
- "version": "0.0.276",
3
+ "version": "0.0.278",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -31,11 +31,11 @@
31
31
  "speakeasy": "^2.0.0",
32
32
  "uuid": "^11.1.0",
33
33
  "@hed-hog/api-pagination": "0.0.6",
34
- "@hed-hog/api-prisma": "0.0.5",
35
34
  "@hed-hog/api-types": "0.0.1",
35
+ "@hed-hog/api-prisma": "0.0.5",
36
36
  "@hed-hog/api-mail": "0.0.8",
37
- "@hed-hog/api": "0.0.4",
38
- "@hed-hog/api-locale": "0.0.13"
37
+ "@hed-hog/api-locale": "0.0.13",
38
+ "@hed-hog/api": "0.0.4"
39
39
  },
40
40
  "exports": {
41
41
  ".": {
@@ -1,18 +1,19 @@
1
1
  import { Public, Role, User, UserOptional } from '@hed-hog/api';
2
2
  import { Locale } from '@hed-hog/api-locale';
3
- import {
4
- BadRequestException,
5
- Body,
6
- Controller,
7
- forwardRef,
8
- Get,
9
- Headers,
10
- Inject,
11
- Ip,
12
- Post,
13
- Req,
14
- Res
15
- } from '@nestjs/common';
3
+ import {
4
+ BadRequestException,
5
+ Body,
6
+ Controller,
7
+ forwardRef,
8
+ Get,
9
+ Headers,
10
+ Inject,
11
+ Ip,
12
+ Post,
13
+ Req,
14
+ Res,
15
+ UnauthorizedException,
16
+ } from '@nestjs/common';
16
17
  import { TokenService } from '../token/token.service';
17
18
  import { CreateWithEmailAndPasswordDTO } from '../user/dto/create-with-email-and-password.dto';
18
19
  import { UserService } from '../user/user.service';
@@ -123,7 +124,7 @@ export class AuthController {
123
124
  @Headers('user-agent') userAgent: string,
124
125
  @Res({ passthrough: true }) res,
125
126
  ) {
126
- const { accessToken, refreshToken, session } = await this.service.verifyMfaCode(
127
+ const { accessToken, refreshToken, session, requiresPasswordReset } = await this.service.verifyMfaCode(
127
128
  locale,
128
129
  token,
129
130
  code,
@@ -133,7 +134,7 @@ export class AuthController {
133
134
  );
134
135
 
135
136
  await this.token.setRefreshTokenCookie(locale, res, refreshToken, session.expires_at);
136
- return { accessToken, refreshToken };
137
+ return { accessToken, refreshToken, requiresPasswordReset };
137
138
  }
138
139
 
139
140
  @Public()
@@ -145,7 +146,7 @@ export class AuthController {
145
146
  @Headers('user-agent') userAgent: string,
146
147
  @Res({ passthrough: true }) res,
147
148
  ) {
148
- const { accessToken, refreshToken, session } = await this.service.verifyMfaRecoveryCode(
149
+ const { accessToken, refreshToken, session, requiresPasswordReset } = await this.service.verifyMfaRecoveryCode(
149
150
  locale,
150
151
  token,
151
152
  code,
@@ -154,7 +155,7 @@ export class AuthController {
154
155
  );
155
156
 
156
157
  await this.token.setRefreshTokenCookie(locale, res, refreshToken, session.expires_at);
157
- return { accessToken, refreshToken };
158
+ return { accessToken, refreshToken, requiresPasswordReset };
158
159
  }
159
160
 
160
161
  @Public()
@@ -184,7 +185,7 @@ export class AuthController {
184
185
  @Headers('user-agent') userAgent: string,
185
186
  @Res({ passthrough: true }) res,
186
187
  ) {
187
- const { accessToken, refreshToken, session } = await this.service.verifyWebAuthnAuthentication(
188
+ const { accessToken, refreshToken, session, requiresPasswordReset } = await this.service.verifyWebAuthnAuthentication(
188
189
  locale,
189
190
  mfaToken,
190
191
  assertionResponse,
@@ -193,7 +194,7 @@ export class AuthController {
193
194
  );
194
195
 
195
196
  await this.token.setRefreshTokenCookie(locale, res, refreshToken, session.expires_at);
196
- return { accessToken, refreshToken };
197
+ return { accessToken, refreshToken, requiresPasswordReset };
197
198
  }
198
199
 
199
200
  @Public()
@@ -215,7 +216,7 @@ export class AuthController {
215
216
  ) {
216
217
  const refreshToken = req.cookies['rt'] || refreshTokenFromBody;
217
218
  if (!refreshToken) {
218
- throw new BadRequestException('Refresh token not provided');
219
+ throw new UnauthorizedException('Refresh token not provided');
219
220
  }
220
221
 
221
222
  await this.service.logout(res, req, refreshToken);
@@ -1,13 +1,14 @@
1
1
  import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import { PrismaService } from '@hed-hog/api-prisma';
3
3
  import { User } from '@hed-hog/api-types';
4
- import {
5
- BadRequestException,
6
- forwardRef,
7
- Inject,
8
- Injectable,
9
- NotFoundException,
10
- } from '@nestjs/common';
4
+ import {
5
+ BadRequestException,
6
+ forwardRef,
7
+ Inject,
8
+ Injectable,
9
+ NotFoundException,
10
+ UnauthorizedException,
11
+ } from '@nestjs/common';
11
12
  import { ChallengeService } from '../challenge/challenge.service';
12
13
  import { MailService as MailManagerService } from '../mail/mail.service';
13
14
  import { SecurityService } from '../security/security.service';
@@ -54,6 +55,31 @@ export class AuthService {
54
55
 
55
56
  }
56
57
 
58
+ private hasPendingPasswordReset(user: User | any) {
59
+ return Boolean(
60
+ user?.user_credential?.some(
61
+ (credential) =>
62
+ credential.type === 'password' &&
63
+ credential.requires_reset === true &&
64
+ !credential.revoked_at,
65
+ ),
66
+ );
67
+ }
68
+
69
+ private async getRequiresPasswordResetByUserId(userId: number) {
70
+ const credential = await this.prisma.user_credential.findFirst({
71
+ where: {
72
+ user_id: userId,
73
+ type: 'password',
74
+ requires_reset: true,
75
+ revoked_at: null,
76
+ },
77
+ select: { id: true },
78
+ });
79
+
80
+ return Boolean(credential?.id);
81
+ }
82
+
57
83
  async requiresMfaForLogin(locale: string, email: string, user: any) {
58
84
 
59
85
  console.log('MFA required, setting up email MFA');
@@ -340,11 +366,13 @@ export class AuthService {
340
366
 
341
367
  await this.token.setRefreshTokenCookie(locale, res, refreshToken, session.expires_at);
342
368
 
369
+ const requiresPasswordReset = this.hasPendingPasswordReset(user);
370
+
343
371
  if (refreshToken) {
344
- return { accessToken, refreshToken };
372
+ return { accessToken, refreshToken, requiresPasswordReset };
345
373
  }
346
374
 
347
- return { accessToken };
375
+ return { accessToken, requiresPasswordReset };
348
376
  }
349
377
 
350
378
  async loginWithEmailAndPassword(
@@ -358,10 +386,17 @@ export class AuthService {
358
386
  const user = await this.user.findUserByEmail(locale, email);
359
387
 
360
388
  if (!user) throw new BadRequestException(getLocaleText('accessDenied', locale, 'Access denied.'));
389
+
390
+ console.debug({ user });
391
+
361
392
  const credentials = (user as unknown as User).user_credential?.filter((c) => c.type === 'password') || [];
393
+
394
+ console.debug({ credentials });
395
+
362
396
  const identifier = user.user_identifier?.find((i) => i.type === 'email' && i.value === email);
363
397
 
364
398
  if (!(await this.security.validatePassword(locale, credentials, password))) {
399
+ console.debug('Invalid password');
365
400
  throw new BadRequestException(getLocaleText('accessDenied', locale, 'Access denied.'));
366
401
  }
367
402
 
@@ -435,14 +470,18 @@ export class AuthService {
435
470
 
436
471
  async verifyUser(locale: string, userId: number) {
437
472
  const user = await this.user.findUserById(locale, userId);
473
+ const requiresPasswordReset = this.hasPendingPasswordReset(user);
438
474
  delete user.user_credential;
439
- return user;
475
+ return {
476
+ ...user,
477
+ requires_password_reset: requiresPasswordReset,
478
+ };
440
479
  }
441
480
 
442
481
  async refreshAccessToken(locale: string, refreshToken: string, ipAddress: string, userAgent: string) {
443
482
 
444
483
  if (!refreshToken) {
445
- throw new BadRequestException(getLocaleText('accessDenied', locale, 'Access denied.'));
484
+ throw new UnauthorizedException(getLocaleText('accessDenied', locale, 'Access denied.'));
446
485
  }
447
486
 
448
487
  const { session, token } = await this.session.refresh(
@@ -492,7 +531,7 @@ export class AuthService {
492
531
  }
493
532
  }
494
533
  },
495
- data: { hash: passwordHash },
534
+ data: { hash: passwordHash, requires_reset: false },
496
535
  });
497
536
 
498
537
  const userIdentifier = await this.prisma.user_identifier.findFirst({
@@ -646,7 +685,10 @@ export class AuthService {
646
685
  );
647
686
 
648
687
  await this.user.registerUserActivity(user.id, "login");
649
- return { accessToken, refreshToken, session };
688
+ const requiresPasswordReset = await this.getRequiresPasswordResetByUserId(
689
+ user.id,
690
+ );
691
+ return { accessToken, refreshToken, session, requiresPasswordReset };
650
692
  }
651
693
 
652
694
  async verifyMfaRecoveryCode(locale: string, mfaToken: string, recoveryCode: string, ipAddress: string, userAgent: string) {
@@ -685,7 +727,10 @@ export class AuthService {
685
727
  );
686
728
 
687
729
  await this.user.registerUserActivity(user.id, "login");
688
- return { accessToken, refreshToken, session };
730
+ const requiresPasswordReset = await this.getRequiresPasswordResetByUserId(
731
+ user.id,
732
+ );
733
+ return { accessToken, refreshToken, session, requiresPasswordReset };
689
734
  }
690
735
 
691
736
  async resendMfaCode(locale: string, mfaToken: string) {
@@ -846,6 +891,9 @@ export class AuthService {
846
891
  );
847
892
 
848
893
  await this.user.registerUserActivity(userId, 'login');
849
- return { accessToken, refreshToken, session };
894
+ const requiresPasswordReset = await this.getRequiresPasswordResetByUserId(
895
+ userId,
896
+ );
897
+ return { accessToken, refreshToken, session, requiresPasswordReset };
850
898
  }
851
899
  }
@@ -355,7 +355,7 @@ export class ProfileService {
355
355
  const passwordHash = await this.security.hashArgon2(newPassword);
356
356
  await this.prisma.user_credential.update({
357
357
  where: { id: credential.id },
358
- data: { hash: passwordHash },
358
+ data: { hash: passwordHash, requires_reset: false },
359
359
  });
360
360
 
361
361
  // Get user data and email for notification
@@ -2,13 +2,13 @@ import { IS_PUBLIC_KEY, WITH_ROLE } from '@hed-hog/api';
2
2
  import { getLocaleText } from '@hed-hog/api-locale';
3
3
  import { PrismaService } from '@hed-hog/api-prisma';
4
4
  import {
5
- CanActivate,
6
- ExecutionContext,
7
- forwardRef,
8
- Inject,
9
- Injectable,
10
- RequestMethod,
11
- UnauthorizedException,
5
+ CanActivate,
6
+ ExecutionContext,
7
+ forwardRef,
8
+ Inject,
9
+ Injectable,
10
+ RequestMethod,
11
+ UnauthorizedException,
12
12
  } from '@nestjs/common';
13
13
  import { METHOD_METADATA } from '@nestjs/common/constants';
14
14
  import { Reflector } from '@nestjs/core';
@@ -16,6 +16,11 @@ import { Request } from 'express';
16
16
 
17
17
  @Injectable()
18
18
  export class RoleGuard implements CanActivate {
19
+ private readonly forcePasswordResetAllowedRoutes = new Set([
20
+ 'PUT /profile/change-password',
21
+ 'GET /auth/verify',
22
+ ]);
23
+
19
24
  constructor(
20
25
  private reflector: Reflector,
21
26
  @Inject(forwardRef(() => PrismaService))
@@ -128,6 +133,30 @@ export class RoleGuard implements CanActivate {
128
133
  throw new UnauthorizedException(message);
129
134
  }
130
135
 
136
+ const hasPendingPasswordReset = await this.prisma.user_credential.findFirst({
137
+ where: {
138
+ user_id: userId,
139
+ type: 'password',
140
+ requires_reset: true,
141
+ revoked_at: null,
142
+ },
143
+ select: { id: true },
144
+ });
145
+
146
+ if (
147
+ hasPendingPasswordReset &&
148
+ !this.forcePasswordResetAllowedRoutes.has(`${httpMethod} ${fullPath}`)
149
+ ) {
150
+ const locale = request['locale'] || 'en';
151
+ throw new UnauthorizedException(
152
+ getLocaleText(
153
+ 'profile.changePassword.required',
154
+ locale,
155
+ 'Password update required before accessing this resource.',
156
+ ),
157
+ );
158
+ }
159
+
131
160
  return true;
132
161
  }
133
162
 
@@ -2,7 +2,7 @@ import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
3
3
  import { PrismaService } from '@hed-hog/api-prisma';
4
4
  import { HttpService } from '@nestjs/axios';
5
- import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, NotFoundException } from '@nestjs/common';
5
+ import { BadRequestException, forwardRef, HttpException, HttpStatus, Inject, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
6
6
  import { firstValueFrom } from 'rxjs';
7
7
  import { SecurityService } from '../security/security.service';
8
8
  import { SettingService } from '../setting/setting.service';
@@ -88,7 +88,7 @@ export class SessionService {
88
88
  });
89
89
 
90
90
  if (!session) {
91
- throw new BadRequestException(getLocaleText('accessDenied', locale, 'Access denied.'));
91
+ throw new UnauthorizedException(getLocaleText('accessDenied', locale, 'Access denied.'));
92
92
  }
93
93
 
94
94
  await this.prisma.user_session.update({
@@ -0,0 +1,11 @@
1
+ import { getLocaleText } from '@hed-hog/api-locale';
2
+ import { IsOptional } from 'class-validator';
3
+ import { IsStrongPasswordWithSettings } from '../../validators/is-strong-password-with-settings.validator';
4
+
5
+ export class ResetPasswordDTO {
6
+ @IsOptional()
7
+ @IsStrongPasswordWithSettings({
8
+ message: (args) => getLocaleText('validation.passwordStrength', args.value),
9
+ })
10
+ password?: string;
11
+ }
@@ -2,24 +2,25 @@ import { Public, Role } from '@hed-hog/api';
2
2
  import { Locale } from '@hed-hog/api-locale';
3
3
  import { Pagination } from '@hed-hog/api-pagination';
4
4
  import {
5
- BadRequestException,
6
- Body,
7
- Controller,
8
- Delete,
9
- Get,
10
- Inject,
11
- Param,
12
- ParseIntPipe,
13
- Patch,
14
- Post,
15
- Res,
16
- UploadedFile,
17
- UseInterceptors,
18
- forwardRef,
5
+ BadRequestException,
6
+ Body,
7
+ Controller,
8
+ Delete,
9
+ Get,
10
+ Inject,
11
+ Param,
12
+ ParseIntPipe,
13
+ Patch,
14
+ Post,
15
+ Res,
16
+ UploadedFile,
17
+ UseInterceptors,
18
+ forwardRef,
19
19
  } from '@nestjs/common';
20
20
  import { FileInterceptor } from '@nestjs/platform-express';
21
21
  import { DeleteDTO } from '../dto/delete.dto';
22
22
  import { CreateWithEmailAndPasswordDTO } from './dto/create-with-email-and-password.dto';
23
+ import { ResetPasswordDTO } from './dto/reset-password.dto';
23
24
  import { UpdateDTO } from './dto/update.dto';
24
25
  import { UserService } from './user.service';
25
26
 
@@ -65,6 +66,15 @@ export class UserController {
65
66
  return this.userService.update(locale, userId, data);
66
67
  }
67
68
 
69
+ @Patch(':userId/reset-password')
70
+ async resetPassword(
71
+ @Param('userId', ParseIntPipe) userId: number,
72
+ @Body() data: ResetPasswordDTO,
73
+ @Locale() locale: string,
74
+ ) {
75
+ return this.userService.resetPassword(locale, userId, data);
76
+ }
77
+
68
78
  @UseInterceptors(
69
79
  FileInterceptor('avatar', {
70
80
  fileFilter: (req, file, cb) => {
@@ -13,6 +13,7 @@ import { DeleteDTO } from '../dto/delete.dto';
13
13
  import { FileService } from '../file/file.service';
14
14
  import { SecurityService } from '../security/security.service';
15
15
  import { CreateWithEmailAndPasswordDTO } from './dto/create-with-email-and-password.dto';
16
+ import { ResetPasswordDTO } from './dto/reset-password.dto';
16
17
  import { UpdateDTO } from './dto/update.dto';
17
18
 
18
19
  // Constants
@@ -28,6 +29,14 @@ const DEFAULT_ROLE_SLUG = 'user';
28
29
  const DEFAULT_LOCALE = 'en';
29
30
  const DAYS_IN_MS = 24 * 60 * 60 * 1000;
30
31
  const NEW_USERS_PERIOD_DAYS = 7;
32
+ const RANDOM_PASSWORD_LENGTH = 16;
33
+
34
+ const PASSWORD_CHARSETS = {
35
+ lowercase: 'abcdefghijkmnopqrstuvwxyz',
36
+ uppercase: 'ABCDEFGHJKLMNPQRSTUVWXYZ',
37
+ numbers: '23456789',
38
+ symbols: '@#$%&*!?-_+',
39
+ } as const;
31
40
 
32
41
  const USER_SORT_FIELDS = [
33
42
  'id',
@@ -197,6 +206,46 @@ export class UserService {
197
206
  });
198
207
  }
199
208
 
209
+ async resetPassword(
210
+ locale: string,
211
+ userId: number,
212
+ { password }: ResetPasswordDTO,
213
+ ) {
214
+ await this.validateUserExists(locale, userId);
215
+
216
+ const nextPassword = password || this.generateRandomPassword();
217
+ const passwordHash = await this.security.hashArgon2(nextPassword);
218
+
219
+ const updateResult = await this.prismaService.user_credential.updateMany({
220
+ where: {
221
+ user_id: userId,
222
+ type: CREDENTIAL_TYPE.PASSWORD,
223
+ },
224
+ data: {
225
+ hash: passwordHash,
226
+ requires_reset: true,
227
+ },
228
+ });
229
+
230
+ if (updateResult.count === 0) {
231
+ await this.prismaService.user_credential.create({
232
+ data: {
233
+ user_id: userId,
234
+ type: CREDENTIAL_TYPE.PASSWORD,
235
+ hash: passwordHash,
236
+ requires_reset: true,
237
+ },
238
+ });
239
+ }
240
+
241
+ await this.registerUserActivity(userId, 'resetPassword');
242
+
243
+ return {
244
+ password: nextPassword,
245
+ requiresReset: true,
246
+ };
247
+ }
248
+
200
249
  async delete(locale: string, { ids }: DeleteDTO) {
201
250
  this.validateDeleteIds(locale, ids);
202
251
 
@@ -424,6 +473,41 @@ export class UserService {
424
473
  return role;
425
474
  }
426
475
 
476
+ private generateRandomPassword(length = RANDOM_PASSWORD_LENGTH) {
477
+ const cryptoObj = globalThis.crypto;
478
+ const groups = [
479
+ PASSWORD_CHARSETS.lowercase,
480
+ PASSWORD_CHARSETS.uppercase,
481
+ PASSWORD_CHARSETS.numbers,
482
+ PASSWORD_CHARSETS.symbols,
483
+ ];
484
+
485
+ const allChars = groups.join('');
486
+ const values = new Uint32Array(length + groups.length);
487
+ cryptoObj.getRandomValues(values);
488
+
489
+ const passwordChars: string[] = [];
490
+
491
+ groups.forEach((group, index) => {
492
+ const randomIndex = values[index] % group.length;
493
+ passwordChars.push(group[randomIndex]);
494
+ });
495
+
496
+ for (let i = groups.length; i < values.length; i++) {
497
+ const randomIndex = values[i] % allChars.length;
498
+ passwordChars.push(allChars[randomIndex]);
499
+ }
500
+
501
+ for (let i = passwordChars.length - 1; i > 0; i--) {
502
+ const randomIndex = values[i] % (i + 1);
503
+ const temp = passwordChars[i];
504
+ passwordChars[i] = passwordChars[randomIndex];
505
+ passwordChars[randomIndex] = temp;
506
+ }
507
+
508
+ return passwordChars.slice(0, length).join('');
509
+ }
510
+
427
511
  private getUserIncludeClause() {
428
512
  return {
429
513
  user_account: true,