create-forgeon 0.3.24 → 0.3.26

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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/modules/accounts-communications.mjs +146 -0
  3. package/src/modules/accounts.mjs +17 -5
  4. package/src/modules/communications.mjs +4 -3
  5. package/src/modules/dependencies.test.mjs +37 -14
  6. package/src/modules/executor.mjs +2 -0
  7. package/src/modules/executor.test.mjs +105 -16
  8. package/src/modules/registry.mjs +24 -8
  9. package/templates/module-fragments/accounts/20_scope.md +4 -5
  10. package/templates/module-fragments/accounts/90_status_implemented.md +2 -2
  11. package/templates/module-fragments/accounts-communications/00_title.md +1 -0
  12. package/templates/module-fragments/accounts-communications/10_overview.md +3 -0
  13. package/templates/module-fragments/accounts-communications/20_scope.md +24 -0
  14. package/templates/module-fragments/accounts-communications/90_status_implemented.md +3 -0
  15. package/templates/module-fragments/communications/20_scope.md +5 -2
  16. package/templates/module-presets/accounts/apps/api/prisma/migrations/0002_accounts_core/migration.sql +22 -1
  17. package/templates/module-presets/accounts/packages/accounts-api/package.json +0 -1
  18. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +122 -117
  19. package/templates/module-presets/accounts/packages/accounts-api/src/auth-pending-operations.ts +9 -0
  20. package/templates/module-presets/accounts/packages/accounts-api/src/auth.controller.ts +2 -21
  21. package/templates/module-presets/accounts/packages/accounts-api/src/auth.handlers.ts +45 -0
  22. package/templates/module-presets/accounts/packages/accounts-api/src/auth.service.ts +19 -18
  23. package/templates/module-presets/accounts/packages/accounts-api/src/auth.store.ts +87 -0
  24. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +29 -5
  25. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +2 -0
  26. package/templates/module-presets/accounts/packages/accounts-contracts/src/index.ts +37 -1
  27. package/templates/module-presets/accounts-communications/packages/accounts-communications/package.json +22 -0
  28. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.controller.ts +69 -0
  29. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/auth-communications.service.ts +221 -0
  30. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/confirmed-change-password.handler.ts +16 -0
  31. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-email.dto.ts +8 -0
  32. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-change-password.dto.ts +8 -0
  33. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/confirm-password-reset.dto.ts +12 -0
  34. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/index.ts +6 -0
  35. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-change-email.dto.ts +7 -0
  36. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/request-password-reset.dto.ts +7 -0
  37. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/dto/verify-email.dto.ts +8 -0
  38. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/index.ts +5 -0
  39. package/templates/module-presets/accounts-communications/packages/accounts-communications/src/pending-verification-register.handler.ts +13 -0
  40. package/templates/module-presets/accounts-communications/packages/accounts-communications/tsconfig.json +9 -0
  41. package/templates/module-presets/communications/packages/communications/src/communications-config.loader.ts +2 -1
  42. package/templates/module-presets/communications/packages/communications/src/communications-env.schema.ts +21 -5
  43. package/templates/module-presets/communications/packages/communications/src/email/providers/gmail-smtp-email.provider.ts +52 -12
  44. package/templates/module-presets/communications/packages/communications/src/forgeon-communications.module.ts +2 -1
@@ -19,10 +19,22 @@ export type RefreshTokenRecord = {
19
19
  createdAt: Date;
20
20
  };
21
21
 
22
+ export type PendingOperationRecord = {
23
+ id: string;
24
+ userId: string;
25
+ type: string;
26
+ tokenHash: string;
27
+ metadata: JsonObject | null;
28
+ expiresAt: Date;
29
+ consumedAt: Date | null;
30
+ createdAt: Date;
31
+ };
32
+
22
33
  export interface CreatePasswordAccountInput {
23
34
  email: string;
24
35
  passwordHash: string;
25
36
  status: string;
37
+ emailVerifiedAt: Date | null;
26
38
  userData: JsonObject | null;
27
39
  profile: {
28
40
  name: string | null;
@@ -45,6 +57,7 @@ export class AuthStore {
45
57
  const user = await tx.user.create({
46
58
  data: {
47
59
  status: input.status,
60
+ emailVerifiedAt: input.emailVerifiedAt,
48
61
  data: toPrismaJsonInput(input.userData),
49
62
  profile: {
50
63
  create: {
@@ -143,6 +156,79 @@ export class AuthStore {
143
156
  });
144
157
  }
145
158
 
159
+ async markEmailVerified(userId: string, verifiedAt: Date): Promise<void> {
160
+ await this.prisma.user.update({
161
+ where: { id: userId },
162
+ data: {
163
+ status: 'active',
164
+ emailVerifiedAt: verifiedAt,
165
+ },
166
+ });
167
+ }
168
+
169
+ async updatePrimaryEmail(userId: string, email: string, verifiedAt: Date): Promise<void> {
170
+ await this.prisma.$transaction([
171
+ this.prisma.authIdentity.updateMany({
172
+ where: { userId, provider: 'email' },
173
+ data: { providerId: email },
174
+ }),
175
+ this.prisma.user.update({
176
+ where: { id: userId },
177
+ data: {
178
+ emailVerifiedAt: verifiedAt,
179
+ },
180
+ }),
181
+ ]);
182
+ }
183
+
184
+ async createPendingOperation(input: {
185
+ id: string;
186
+ userId: string;
187
+ type: string;
188
+ tokenHash: string;
189
+ metadata: JsonObject | null;
190
+ expiresAt: Date;
191
+ }): Promise<void> {
192
+ await this.prisma.authPendingOperation.create({
193
+ data: {
194
+ id: input.id,
195
+ userId: input.userId,
196
+ type: input.type,
197
+ tokenHash: input.tokenHash,
198
+ metadata: toPrismaJsonInput(input.metadata),
199
+ expiresAt: input.expiresAt,
200
+ },
201
+ });
202
+ }
203
+
204
+ async findPendingOperationById(id: string): Promise<PendingOperationRecord | null> {
205
+ const operation = await this.prisma.authPendingOperation.findUnique({
206
+ where: { id },
207
+ });
208
+
209
+ if (!operation) {
210
+ return null;
211
+ }
212
+
213
+ return {
214
+ id: operation.id,
215
+ userId: operation.userId,
216
+ type: operation.type,
217
+ tokenHash: operation.tokenHash,
218
+ metadata: operation.metadata as JsonObject | null,
219
+ expiresAt: operation.expiresAt,
220
+ consumedAt: operation.consumedAt,
221
+ createdAt: operation.createdAt,
222
+ };
223
+ }
224
+
225
+ async consumePendingOperation(id: string, consumedAt: Date): Promise<void> {
226
+ await this.prisma.authPendingOperation.updateMany({
227
+ where: { id, consumedAt: null },
228
+ data: { consumedAt },
229
+ });
230
+ }
231
+
146
232
  async createRefreshToken(input: {
147
233
  id: string;
148
234
  userId: string;
@@ -190,6 +276,7 @@ export class AuthStore {
190
276
  private mapPasswordAccount(user: {
191
277
  id: string;
192
278
  status: string;
279
+ emailVerifiedAt: Date | null;
193
280
  data: unknown;
194
281
  createdAt: Date;
195
282
  updatedAt: Date;
@@ -3,23 +3,31 @@ import {
3
3
  Module,
4
4
  ModuleMetadata,
5
5
  Provider,
6
+ Type,
6
7
  } from '@nestjs/common';
7
8
  import { JwtModule } from '@nestjs/jwt';
8
9
  import { PassportModule } from '@nestjs/passport';
9
- import { ForgeonCommunicationsModule } from '@forgeon/communications';
10
10
  import { DbPrismaModule } from '@forgeon/db-prisma';
11
11
  import {
12
12
  ACCOUNTS_AUTHZ_CLAIMS_RESOLVER,
13
13
  NoopAccountsAuthzClaimsResolver,
14
14
  } from './accounts-rbac.port';
15
+ import { JwtAuthGuard } from './access-token.guard';
15
16
  import { AuthConfigModule } from './auth-config.module';
16
17
  import { AuthController } from './auth.controller';
17
18
  import { AuthCoreService } from './auth-core.service';
19
+ import {
20
+ CHANGE_PASSWORD_HANDLER,
21
+ DefaultChangePasswordHandler,
22
+ DefaultRegisterHandler,
23
+ REGISTER_HANDLER,
24
+ type ChangePasswordHandler,
25
+ type RegisterHandler,
26
+ } from './auth.handlers';
18
27
  import { AuthJwtService } from './auth-jwt.service';
19
28
  import { AuthPasswordService } from './auth-password.service';
20
29
  import { AuthService } from './auth.service';
21
30
  import { AuthStore } from './auth.store';
22
- import { JwtAuthGuard } from './access-token.guard';
23
31
  import { JwtStrategy } from './jwt.strategy';
24
32
  import { OwnerAccessGuard } from './owner-access.guard';
25
33
  import { UsersController } from './users.controller';
@@ -30,7 +38,12 @@ import { UsersStore } from './users.store';
30
38
  export interface ForgeonAccountsModuleOptions {
31
39
  imports?: ModuleMetadata['imports'];
32
40
  providers?: Provider[];
41
+ controllers?: Type<unknown>[];
33
42
  users?: UsersModuleOptions;
43
+ handlers?: {
44
+ register?: Type<RegisterHandler>;
45
+ changePassword?: Type<ChangePasswordHandler>;
46
+ };
34
47
  }
35
48
 
36
49
  @Module({})
@@ -41,12 +54,11 @@ export class ForgeonAccountsModule {
41
54
  imports: [
42
55
  AuthConfigModule,
43
56
  DbPrismaModule,
44
- ForgeonCommunicationsModule.register(),
45
57
  PassportModule.register({ defaultStrategy: 'jwt' }),
46
58
  JwtModule.register({}),
47
59
  ...(options.imports ?? []),
48
60
  ],
49
- controllers: [AuthController, UsersController],
61
+ controllers: [AuthController, UsersController, ...(options.controllers ?? [])],
50
62
  providers: [
51
63
  {
52
64
  provide: USERS_MODULE_OPTIONS,
@@ -59,6 +71,16 @@ export class ForgeonAccountsModule {
59
71
  AuthStore,
60
72
  UsersStore,
61
73
  AuthCoreService,
74
+ DefaultRegisterHandler,
75
+ DefaultChangePasswordHandler,
76
+ {
77
+ provide: REGISTER_HANDLER,
78
+ useExisting: options.handlers?.register ?? DefaultRegisterHandler,
79
+ },
80
+ {
81
+ provide: CHANGE_PASSWORD_HANDLER,
82
+ useExisting: options.handlers?.changePassword ?? DefaultChangePasswordHandler,
83
+ },
62
84
  AuthJwtService,
63
85
  AuthPasswordService,
64
86
  AuthService,
@@ -71,6 +93,8 @@ export class ForgeonAccountsModule {
71
93
  exports: [
72
94
  AuthConfigModule,
73
95
  AuthCoreService,
96
+ REGISTER_HANDLER,
97
+ CHANGE_PASSWORD_HANDLER,
74
98
  AuthJwtService,
75
99
  AuthPasswordService,
76
100
  AuthService,
@@ -81,4 +105,4 @@ export class ForgeonAccountsModule {
81
105
  ],
82
106
  };
83
107
  }
84
- }
108
+ }
@@ -5,8 +5,10 @@ export * from './auth-config.service';
5
5
  export * from './auth.controller';
6
6
  export * from './auth-core.service';
7
7
  export * from './auth-env.schema';
8
+ export * from './auth.handlers';
8
9
  export * from './auth-jwt.service';
9
10
  export * from './auth-password.service';
11
+ export * from './auth-pending-operations';
10
12
  export * from './auth.service';
11
13
  export * from './auth.store';
12
14
  export * from './auth.types';
@@ -5,9 +5,12 @@ export const AUTH_API_ROUTES = {
5
5
  logout: '/api/auth/logout',
6
6
  me: '/api/auth/me',
7
7
  changePassword: '/api/auth/change-password',
8
+ changePasswordConfirm: '/api/auth/change-password/confirm',
8
9
  verifyEmail: '/api/auth/verify-email',
9
10
  passwordResetRequest: '/api/auth/password-reset/request',
10
11
  passwordResetConfirm: '/api/auth/password-reset/confirm',
12
+ changeEmailRequest: '/api/auth/change-email/request',
13
+ changeEmailConfirm: '/api/auth/change-email/confirm',
11
14
  } as const;
12
15
 
13
16
  export const USERS_API_ROUTES = {
@@ -99,6 +102,18 @@ export interface VerifyEmailRequest {
99
102
  token: string;
100
103
  }
101
104
 
105
+ export interface ConfirmChangePasswordRequest {
106
+ token: string;
107
+ }
108
+
109
+ export interface RequestChangeEmailRequest {
110
+ email: string;
111
+ }
112
+
113
+ export interface ConfirmChangeEmailRequest {
114
+ token: string;
115
+ }
116
+
102
117
  export interface UpdateUserRequest {
103
118
  data?: JsonObject;
104
119
  }
@@ -116,4 +131,25 @@ export interface TokenPair {
116
131
 
117
132
  export interface AuthSessionResponse extends TokenPair {
118
133
  user: UserRecordDto;
119
- }
134
+ }
135
+
136
+ export interface PendingVerificationResponse {
137
+ status: 'pending_verification';
138
+ message: string;
139
+ }
140
+
141
+ export interface CompletedChangePasswordResponse {
142
+ status: 'completed';
143
+ message?: string;
144
+ }
145
+
146
+ export interface PendingConfirmationChangePasswordResponse {
147
+ status: 'pending_confirmation';
148
+ message: string;
149
+ }
150
+
151
+ export type RegisterResult = AuthSessionResponse | PendingVerificationResponse;
152
+ export type VerifyEmailResult = AuthSessionResponse;
153
+ export type ChangePasswordResult =
154
+ | CompletedChangePasswordResponse
155
+ | PendingConfirmationChangePasswordResponse;
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@forgeon/accounts-communications",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc -p tsconfig.json"
9
+ },
10
+ "dependencies": {
11
+ "@forgeon/accounts-api": "workspace:*",
12
+ "@forgeon/accounts-contracts": "workspace:*",
13
+ "@forgeon/communications": "workspace:*",
14
+ "@nestjs/common": "^11.0.1",
15
+ "class-transformer": "^0.5.1",
16
+ "class-validator": "^0.14.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.10.7",
20
+ "typescript": "^5.7.3"
21
+ }
22
+ }
@@ -0,0 +1,69 @@
1
+ import {
2
+ Body,
3
+ Controller,
4
+ Post,
5
+ Req,
6
+ UseGuards,
7
+ } from '@nestjs/common';
8
+ import { JwtAuthGuard } from '@forgeon/accounts-api';
9
+ import type { AuthAccessTokenPayload } from '@forgeon/accounts-api';
10
+ import { AuthCommunicationsService } from './auth-communications.service';
11
+ import {
12
+ ConfirmChangeEmailDto,
13
+ ConfirmChangePasswordDto,
14
+ ConfirmPasswordResetDto,
15
+ RequestChangeEmailDto,
16
+ RequestPasswordResetDto,
17
+ VerifyEmailDto,
18
+ } from './dto';
19
+
20
+ type RequestWithUser = { user?: AuthAccessTokenPayload };
21
+
22
+ @Controller('auth')
23
+ export class AuthCommunicationsController {
24
+ constructor(private readonly authCommunicationsService: AuthCommunicationsService) {}
25
+
26
+ @Post('verify-email')
27
+ verifyEmail(@Body() body: VerifyEmailDto) {
28
+ return this.authCommunicationsService.verifyEmail(body.token);
29
+ }
30
+
31
+ @Post('password-reset/request')
32
+ requestPasswordReset(@Body() body: RequestPasswordResetDto) {
33
+ return this.authCommunicationsService.requestPasswordReset(body.email);
34
+ }
35
+
36
+ @Post('password-reset/confirm')
37
+ confirmPasswordReset(@Body() body: ConfirmPasswordResetDto) {
38
+ return this.authCommunicationsService.confirmPasswordReset(body.token, body.newPassword);
39
+ }
40
+
41
+ @Post('change-password/confirm')
42
+ confirmChangePassword(@Body() body: ConfirmChangePasswordDto) {
43
+ return this.authCommunicationsService.confirmChangePassword(body.token);
44
+ }
45
+
46
+ @UseGuards(JwtAuthGuard)
47
+ @Post('change-email/request')
48
+ requestChangeEmail(@Body() body: RequestChangeEmailDto, @Req() request: RequestWithUser) {
49
+ const user = this.getRequestUser(request);
50
+ return this.authCommunicationsService.requestChangeEmail(user.sub, body);
51
+ }
52
+
53
+ @Post('change-email/confirm')
54
+ confirmChangeEmail(@Body() body: ConfirmChangeEmailDto) {
55
+ return this.authCommunicationsService.confirmChangeEmail(body.token);
56
+ }
57
+
58
+ private getRequestUser(request: RequestWithUser): AuthAccessTokenPayload {
59
+ const user = request.user;
60
+ if (!user?.sub) {
61
+ return {
62
+ sub: 'unknown',
63
+ email: 'unknown@invalid.local',
64
+ type: 'access',
65
+ };
66
+ }
67
+ return user;
68
+ }
69
+ }
@@ -0,0 +1,221 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type {
3
+ ChangePasswordRequest,
4
+ ChangePasswordResult,
5
+ PendingVerificationResponse,
6
+ RegisterRequest,
7
+ RequestChangeEmailRequest,
8
+ VerifyEmailResult,
9
+ } from '@forgeon/accounts-contracts';
10
+ import {
11
+ AUTH_PENDING_OPERATION_TYPES,
12
+ AuthCoreService,
13
+ AuthPasswordService,
14
+ } from '@forgeon/accounts-api';
15
+ import { CommunicationsService } from '@forgeon/communications';
16
+
17
+ @Injectable()
18
+ export class AuthCommunicationsService {
19
+ constructor(
20
+ private readonly authCoreService: AuthCoreService,
21
+ private readonly authPasswordService: AuthPasswordService,
22
+ private readonly communicationsService: CommunicationsService,
23
+ ) {}
24
+
25
+ async registerWithPendingVerification(input: RegisterRequest): Promise<PendingVerificationResponse> {
26
+ const account = await this.authCoreService.createPasswordAccount(input, {
27
+ status: 'pending_verification',
28
+ emailVerifiedAt: null,
29
+ });
30
+ const operation = await this.authCoreService.issuePendingOperation({
31
+ userId: account.id,
32
+ type: AUTH_PENDING_OPERATION_TYPES.emailVerification,
33
+ });
34
+
35
+ await this.communicationsService.send({
36
+ kind: 'email_verification_code',
37
+ channels: ['email'],
38
+ recipient: { email: account.providerId },
39
+ payload: {
40
+ NAME: account.profile?.name ?? 'there',
41
+ TOKEN: operation.token,
42
+ },
43
+ locale: account.settings?.locale ?? undefined,
44
+ metadata: {
45
+ USER_ID: account.id,
46
+ SOURCE: 'accounts-communications.register',
47
+ },
48
+ });
49
+
50
+ return {
51
+ status: 'pending_verification',
52
+ message: 'Verification email sent',
53
+ };
54
+ }
55
+
56
+ async verifyEmail(token: string): Promise<VerifyEmailResult> {
57
+ const operation = await this.authCoreService.readPendingOperation(
58
+ token,
59
+ AUTH_PENDING_OPERATION_TYPES.emailVerification,
60
+ );
61
+ const account = await this.authCoreService.markEmailVerified(operation.userId);
62
+ await this.authCoreService.consumePendingOperation(operation.id);
63
+ await this.sendWelcomeEmailPlaceholder(account.providerId);
64
+ return this.authCoreService.issueSessionForAccount(account);
65
+ }
66
+
67
+ async requestPasswordReset(email: string) {
68
+ const account = await this.authCoreService.findPasswordAccountByEmail(email);
69
+ if (account) {
70
+ const operation = await this.authCoreService.issuePendingOperation({
71
+ userId: account.id,
72
+ type: AUTH_PENDING_OPERATION_TYPES.passwordReset,
73
+ });
74
+
75
+ await this.communicationsService.send({
76
+ kind: 'password_reset',
77
+ channels: ['email'],
78
+ recipient: { email: account.providerId },
79
+ payload: {
80
+ NAME: account.profile?.name ?? 'there',
81
+ TOKEN: operation.token,
82
+ },
83
+ locale: account.settings?.locale ?? undefined,
84
+ metadata: {
85
+ USER_ID: account.id,
86
+ SOURCE: 'accounts-communications.password-reset',
87
+ },
88
+ });
89
+ }
90
+
91
+ return {
92
+ status: 'accepted',
93
+ message: 'If the account exists, a reset email was sent',
94
+ };
95
+ }
96
+
97
+ async confirmPasswordReset(token: string, newPassword: string) {
98
+ const operation = await this.authCoreService.readPendingOperation(
99
+ token,
100
+ AUTH_PENDING_OPERATION_TYPES.passwordReset,
101
+ );
102
+ const passwordHash = await this.authPasswordService.hash(newPassword);
103
+ await this.authCoreService.applyPasswordHash(operation.userId, passwordHash);
104
+ await this.authCoreService.consumePendingOperation(operation.id);
105
+ return {
106
+ status: 'completed',
107
+ message: 'Password reset confirmed',
108
+ };
109
+ }
110
+
111
+ async startConfirmedChangePassword(
112
+ userId: string,
113
+ input: ChangePasswordRequest,
114
+ ): Promise<ChangePasswordResult> {
115
+ const account = await this.authCoreService.findAccountByUserIdOrThrow(userId);
116
+ const passwordHash = await this.authPasswordService.hash(input.newPassword);
117
+ const operation = await this.authCoreService.issuePendingOperation({
118
+ userId,
119
+ type: AUTH_PENDING_OPERATION_TYPES.passwordChange,
120
+ metadata: {
121
+ passwordHash,
122
+ },
123
+ });
124
+
125
+ await this.communicationsService.send({
126
+ kind: 'password_change_confirmation',
127
+ channels: ['email'],
128
+ recipient: { email: account.providerId },
129
+ payload: {
130
+ NAME: account.profile?.name ?? 'there',
131
+ TOKEN: operation.token,
132
+ },
133
+ locale: account.settings?.locale ?? undefined,
134
+ metadata: {
135
+ USER_ID: account.id,
136
+ SOURCE: 'accounts-communications.change-password',
137
+ },
138
+ });
139
+
140
+ return {
141
+ status: 'pending_confirmation',
142
+ message: 'Password change confirmation sent',
143
+ };
144
+ }
145
+
146
+ async confirmChangePassword(token: string) {
147
+ const operation = await this.authCoreService.readPendingOperation(
148
+ token,
149
+ AUTH_PENDING_OPERATION_TYPES.passwordChange,
150
+ );
151
+ const passwordHash = this.readMetadataString(operation.metadata, 'passwordHash');
152
+ await this.authCoreService.applyPasswordHash(operation.userId, passwordHash);
153
+ await this.authCoreService.consumePendingOperation(operation.id);
154
+ return {
155
+ status: 'completed',
156
+ message: 'Password changed successfully',
157
+ };
158
+ }
159
+
160
+ async requestChangeEmail(userId: string, input: RequestChangeEmailRequest) {
161
+ const account = await this.authCoreService.findAccountByUserIdOrThrow(userId);
162
+ const nextEmail = input.email.trim().toLowerCase();
163
+ const operation = await this.authCoreService.issuePendingOperation({
164
+ userId,
165
+ type: AUTH_PENDING_OPERATION_TYPES.emailChange,
166
+ metadata: {
167
+ email: nextEmail,
168
+ },
169
+ });
170
+
171
+ await this.communicationsService.send({
172
+ kind: 'email_change_confirmation',
173
+ channels: ['email'],
174
+ recipient: { email: nextEmail },
175
+ payload: {
176
+ NAME: account.profile?.name ?? 'there',
177
+ TOKEN: operation.token,
178
+ },
179
+ locale: account.settings?.locale ?? undefined,
180
+ metadata: {
181
+ USER_ID: account.id,
182
+ SOURCE: 'accounts-communications.change-email',
183
+ },
184
+ });
185
+
186
+ return {
187
+ status: 'accepted',
188
+ message: 'Email change confirmation sent',
189
+ };
190
+ }
191
+
192
+ async confirmChangeEmail(token: string) {
193
+ const operation = await this.authCoreService.readPendingOperation(
194
+ token,
195
+ AUTH_PENDING_OPERATION_TYPES.emailChange,
196
+ );
197
+ const nextEmail = this.readMetadataString(operation.metadata, 'email');
198
+ await this.authCoreService.updatePrimaryEmail(operation.userId, nextEmail);
199
+ await this.authCoreService.consumePendingOperation(operation.id);
200
+ return {
201
+ status: 'completed',
202
+ message: 'Email changed successfully',
203
+ };
204
+ }
205
+
206
+ async sendWelcomeEmailPlaceholder(_email: string): Promise<void> {
207
+ return;
208
+ }
209
+
210
+ async sendAccountNotificationPlaceholder(_email: string): Promise<void> {
211
+ return;
212
+ }
213
+
214
+ private readMetadataString(metadata: Record<string, unknown> | null, key: string): string {
215
+ const value = metadata?.[key];
216
+ if (typeof value !== 'string' || value.trim().length === 0) {
217
+ throw new Error(`Missing pending operation metadata: ${key}`);
218
+ }
219
+ return value;
220
+ }
221
+ }
@@ -0,0 +1,16 @@
1
+ import { Injectable } from '@nestjs/common';
2
+ import type {
3
+ ChangePasswordRequest,
4
+ ChangePasswordResult,
5
+ } from '@forgeon/accounts-contracts';
6
+ import type { ChangePasswordHandler } from '@forgeon/accounts-api';
7
+ import { AuthCommunicationsService } from './auth-communications.service';
8
+
9
+ @Injectable()
10
+ export class ConfirmedChangePasswordHandler implements ChangePasswordHandler {
11
+ constructor(private readonly authCommunicationsService: AuthCommunicationsService) {}
12
+
13
+ execute(userId: string, input: ChangePasswordRequest): Promise<ChangePasswordResult> {
14
+ return this.authCommunicationsService.startConfirmedChangePassword(userId, input);
15
+ }
16
+ }
@@ -0,0 +1,8 @@
1
+ import type { ConfirmChangeEmailRequest } from '@forgeon/accounts-contracts';
2
+ import { IsString, MinLength } from 'class-validator';
3
+
4
+ export class ConfirmChangeEmailDto implements ConfirmChangeEmailRequest {
5
+ @IsString()
6
+ @MinLength(8)
7
+ token!: string;
8
+ }
@@ -0,0 +1,8 @@
1
+ import type { ConfirmChangePasswordRequest } from '@forgeon/accounts-contracts';
2
+ import { IsString, MinLength } from 'class-validator';
3
+
4
+ export class ConfirmChangePasswordDto implements ConfirmChangePasswordRequest {
5
+ @IsString()
6
+ @MinLength(8)
7
+ token!: string;
8
+ }
@@ -0,0 +1,12 @@
1
+ import type { ConfirmPasswordResetRequest } from '@forgeon/accounts-contracts';
2
+ import { IsString, MinLength } from 'class-validator';
3
+
4
+ export class ConfirmPasswordResetDto implements ConfirmPasswordResetRequest {
5
+ @IsString()
6
+ @MinLength(8)
7
+ token!: string;
8
+
9
+ @IsString()
10
+ @MinLength(8)
11
+ newPassword!: string;
12
+ }
@@ -0,0 +1,6 @@
1
+ export * from './confirm-change-email.dto';
2
+ export * from './confirm-change-password.dto';
3
+ export * from './confirm-password-reset.dto';
4
+ export * from './request-change-email.dto';
5
+ export * from './request-password-reset.dto';
6
+ export * from './verify-email.dto';
@@ -0,0 +1,7 @@
1
+ import type { RequestChangeEmailRequest } from '@forgeon/accounts-contracts';
2
+ import { IsEmail } from 'class-validator';
3
+
4
+ export class RequestChangeEmailDto implements RequestChangeEmailRequest {
5
+ @IsEmail()
6
+ email!: string;
7
+ }
@@ -0,0 +1,7 @@
1
+ import type { RequestPasswordResetRequest } from '@forgeon/accounts-contracts';
2
+ import { IsEmail } from 'class-validator';
3
+
4
+ export class RequestPasswordResetDto implements RequestPasswordResetRequest {
5
+ @IsEmail()
6
+ email!: string;
7
+ }
@@ -0,0 +1,8 @@
1
+ import type { VerifyEmailRequest } from '@forgeon/accounts-contracts';
2
+ import { IsString, MinLength } from 'class-validator';
3
+
4
+ export class VerifyEmailDto implements VerifyEmailRequest {
5
+ @IsString()
6
+ @MinLength(8)
7
+ token!: string;
8
+ }
@@ -0,0 +1,5 @@
1
+ export * from './auth-communications.controller';
2
+ export * from './auth-communications.service';
3
+ export * from './confirmed-change-password.handler';
4
+ export * from './pending-verification-register.handler';
5
+ export * from './dto';