@skroz/profile-api 1.0.5

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 (65) hide show
  1. package/LICENCE.md +21 -0
  2. package/dist/adapters/TypeOrmProfileAdapter.d.ts +13 -0
  3. package/dist/adapters/TypeOrmProfileAdapter.js +54 -0
  4. package/dist/dto/ConfirmEmailInput.d.ts +6 -0
  5. package/dist/dto/ConfirmEmailInput.js +27 -0
  6. package/dist/dto/ForgotPasswordInput.d.ts +9 -0
  7. package/dist/dto/ForgotPasswordInput.js +31 -0
  8. package/dist/dto/LoginInput.d.ts +12 -0
  9. package/dist/dto/LoginInput.js +31 -0
  10. package/dist/dto/PasswordInput.d.ts +9 -0
  11. package/dist/dto/PasswordInput.js +31 -0
  12. package/dist/dto/RecoverPasswordInput.d.ts +12 -0
  13. package/dist/dto/RecoverPasswordInput.js +28 -0
  14. package/dist/dto/RegisterInput.d.ts +16 -0
  15. package/dist/dto/RegisterInput.js +40 -0
  16. package/dist/dto/SendTokenPayload.d.ts +4 -0
  17. package/dist/dto/SendTokenPayload.js +27 -0
  18. package/dist/dto/StatusPayload.d.ts +3 -0
  19. package/dist/dto/StatusPayload.js +23 -0
  20. package/dist/dto/UpdateEmailInput.d.ts +9 -0
  21. package/dist/dto/UpdateEmailInput.js +31 -0
  22. package/dist/dto/UpdatePasswordInput.d.ts +12 -0
  23. package/dist/dto/UpdatePasswordInput.js +28 -0
  24. package/dist/dto/UpdateProfileInput.d.ts +9 -0
  25. package/dist/dto/UpdateProfileInput.js +31 -0
  26. package/dist/dto/index.d.ts +11 -0
  27. package/dist/dto/index.js +27 -0
  28. package/dist/entities/TypeOrmBaseUser.d.ts +25 -0
  29. package/dist/entities/TypeOrmBaseUser.js +117 -0
  30. package/dist/index.d.ts +8 -0
  31. package/dist/index.js +24 -0
  32. package/dist/resolvers/AuthResolver.d.ts +38 -0
  33. package/dist/resolvers/AuthResolver.js +219 -0
  34. package/dist/resolvers/ProfileResolver.d.ts +24 -0
  35. package/dist/resolvers/ProfileResolver.js +145 -0
  36. package/dist/services/ProfileAuthService.d.ts +24 -0
  37. package/dist/services/ProfileAuthService.js +94 -0
  38. package/dist/services/ProfileEmailService.d.ts +15 -0
  39. package/dist/services/ProfileEmailService.js +105 -0
  40. package/dist/types/index.d.ts +98 -0
  41. package/dist/types/index.js +10 -0
  42. package/dist/validators/isTrue.d.ts +3 -0
  43. package/dist/validators/isTrue.js +7 -0
  44. package/package.json +45 -0
  45. package/src/adapters/TypeOrmProfileAdapter.ts +40 -0
  46. package/src/dto/ConfirmEmailInput.ts +12 -0
  47. package/src/dto/ForgotPasswordInput.ts +17 -0
  48. package/src/dto/LoginInput.ts +20 -0
  49. package/src/dto/PasswordInput.ts +17 -0
  50. package/src/dto/RecoverPasswordInput.ts +20 -0
  51. package/src/dto/RegisterInput.ts +29 -0
  52. package/src/dto/SendTokenPayload.ts +10 -0
  53. package/src/dto/StatusPayload.ts +7 -0
  54. package/src/dto/UpdateEmailInput.ts +17 -0
  55. package/src/dto/UpdatePasswordInput.ts +20 -0
  56. package/src/dto/UpdateProfileInput.ts +17 -0
  57. package/src/dto/index.ts +11 -0
  58. package/src/entities/TypeOrmBaseUser.ts +87 -0
  59. package/src/index.ts +8 -0
  60. package/src/resolvers/AuthResolver.ts +195 -0
  61. package/src/resolvers/ProfileResolver.ts +107 -0
  62. package/src/services/ProfileAuthService.ts +122 -0
  63. package/src/services/ProfileEmailService.ts +158 -0
  64. package/src/types/index.ts +102 -0
  65. package/src/validators/isTrue.ts +8 -0
@@ -0,0 +1,87 @@
1
+ import {
2
+ Column,
3
+ CreateDateColumn,
4
+ PrimaryGeneratedColumn,
5
+ UpdateDateColumn,
6
+ BaseEntity as TypeORMBaseEntity,
7
+ } from 'typeorm';
8
+ import { Field, ID, ObjectType, GraphQLTimestamp } from 'type-graphql';
9
+
10
+ @ObjectType({ isAbstract: true })
11
+ export abstract class TypeOrmBaseUser extends TypeORMBaseEntity {
12
+ public static config = {
13
+ isOnlineSeconds: 60,
14
+ isOnlineRecentlySeconds: 300,
15
+ };
16
+
17
+ @Field(() => ID)
18
+ @PrimaryGeneratedColumn()
19
+ public id!: number;
20
+
21
+ @Field(() => GraphQLTimestamp)
22
+ @CreateDateColumn({ type: 'timestamptz' })
23
+ public createdAt!: Date;
24
+
25
+ @Field(() => GraphQLTimestamp)
26
+ @UpdateDateColumn({ type: 'timestamptz' })
27
+ public updatedAt!: Date;
28
+
29
+ @Column({ type: 'varchar', nullable: true, unique: true })
30
+ public email!: string | null;
31
+
32
+ @Column()
33
+ public password!: string;
34
+
35
+ @Column({ default: false })
36
+ public isTempPassword!: boolean;
37
+
38
+ @Column({ default: false })
39
+ public isEmailConfirmed!: boolean;
40
+
41
+ @Field(() => Boolean)
42
+ @Column({ default: true })
43
+ public isEmailNotificationEnabled!: boolean;
44
+
45
+ @Field(() => Boolean)
46
+ @Column({ default: true })
47
+ public isTelegramNotificationEnabled!: boolean;
48
+
49
+ @Field(() => Boolean)
50
+ @Column({ default: false })
51
+ public isBanned!: boolean;
52
+
53
+ @Field(() => Boolean)
54
+ @Column({ default: false })
55
+ public isDeleted!: boolean;
56
+
57
+ @Column({ type: 'varchar', nullable: true, unique: true })
58
+ public urlSlug?: string;
59
+
60
+ @Column({ type: 'varchar', nullable: true, unique: true })
61
+ public telegramId!: string | null;
62
+
63
+ @Field(() => String, { nullable: true })
64
+ @Column({ type: 'varchar', nullable: true })
65
+ public avatar?: string | null;
66
+
67
+ @Field(() => GraphQLTimestamp)
68
+ @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
69
+ public lastSeenAt!: Date;
70
+
71
+ public checkOnline(seconds: number): boolean {
72
+ if (!this.lastSeenAt) return false;
73
+ const filterDate = new Date();
74
+ filterDate.setSeconds(filterDate.getSeconds() - seconds);
75
+ return this.lastSeenAt > filterDate;
76
+ }
77
+
78
+ @Field(() => Boolean)
79
+ get isOnline(): boolean {
80
+ return this.checkOnline(TypeOrmBaseUser.config.isOnlineSeconds);
81
+ }
82
+
83
+ @Field(() => Boolean)
84
+ get isOnlineRecently(): boolean {
85
+ return this.checkOnline(TypeOrmBaseUser.config.isOnlineRecentlySeconds);
86
+ }
87
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './types';
2
+ export * from './adapters/TypeOrmProfileAdapter';
3
+ export * from './entities/TypeOrmBaseUser';
4
+ export * from './services/ProfileAuthService';
5
+ export * from './services/ProfileEmailService';
6
+ export * from './dto';
7
+ export * from './resolvers/AuthResolver';
8
+ export * from './resolvers/ProfileResolver';
@@ -0,0 +1,195 @@
1
+ import {
2
+ Arg,
3
+ Authorized,
4
+ Ctx,
5
+ Mutation,
6
+ Resolver,
7
+ } from 'type-graphql';
8
+ import { StatusPayload } from '@os-team/graphql-utils';
9
+ import { TransformArgs } from '@os-team/graphql-transformers';
10
+ import { ValidateArgs } from '@os-team/graphql-validators';
11
+ import { ProfileAuthService, CONFIRMATION_REDIS_PREFIX, RECOVERY_REDIS_PREFIX } from '../services/ProfileAuthService';
12
+ import { ProfileContext } from '../types';
13
+ import {
14
+ RegisterInput, registerTransformers, registerValidators,
15
+ LoginInput, loginTransformers, loginValidators,
16
+ ConfirmEmailInput,
17
+ ForgotPasswordInput, forgotPasswordTransformers, forgotPasswordValidators,
18
+ RecoverPasswordInput, recoverPasswordTransformers, recoverPasswordValidators,
19
+ SendTokenPayload,
20
+ confirmEmailValidators
21
+ } from '../dto';
22
+
23
+ export interface AuthResolverDependencies<TContext extends ProfileContext = ProfileContext> {
24
+ authService: ProfileAuthService;
25
+ userType: any;
26
+ onUserCreated?: (user: any, ctx: TContext) => Promise<void>;
27
+ onLogin?: (user: any, ctx: TContext) => Promise<void>;
28
+ onLogout?: (user: any, ctx: TContext) => Promise<void>;
29
+ onEmailConfirmed?: (user: any, ctx: TContext) => Promise<void>;
30
+ onPasswordRecovered?: (user: any, ctx: TContext) => Promise<void>;
31
+ logTelegramBot?: { sendError: (msg: string) => Promise<any> };
32
+ }
33
+
34
+ export function createAuthResolver<TContext extends ProfileContext = ProfileContext>(deps: AuthResolverDependencies<TContext>) {
35
+ const {
36
+ authService,
37
+ onUserCreated,
38
+ onLogin,
39
+ onLogout,
40
+ onEmailConfirmed,
41
+ onPasswordRecovered,
42
+ logTelegramBot,
43
+ userType
44
+ } = deps;
45
+
46
+ @Resolver(() => userType)
47
+ class AuthResolver {
48
+ @TransformArgs(registerTransformers, { arg: 'input' })
49
+ @ValidateArgs(registerValidators, { arg: 'input', tKey: 'register' })
50
+ @Mutation(() => StatusPayload)
51
+ async register(@Arg('input') input: RegisterInput, @Ctx() ctx: TContext) {
52
+ const { t } = ctx;
53
+ if (await authService.db.isEmailTaken(input.email)) {
54
+ throw new Error(t('validation:auth.emailExists'));
55
+ }
56
+
57
+ const passwordHash = await authService.hashPassword(input.password);
58
+ const user = await authService.db.createUser({
59
+ email: input.email,
60
+ passwordHash,
61
+ });
62
+
63
+ if (onUserCreated) await onUserCreated(user, ctx);
64
+ if (logTelegramBot) await logTelegramBot.sendError(`Новый пользователь ${input.email}`);
65
+
66
+ // Создаем сессию
67
+ const userAgent = decodeURI(ctx.req.get('user-agent') || '');
68
+ await ctx.req.session.create({
69
+ userId: user.id,
70
+ ip: ctx.req.ip,
71
+ userAgent: userAgent.slice(0, 500),
72
+ });
73
+
74
+ return { ok: true };
75
+ }
76
+
77
+ @TransformArgs(loginTransformers, { arg: 'input' })
78
+ @ValidateArgs(loginValidators, { arg: 'input', tKey: 'register' })
79
+ @Mutation(() => StatusPayload)
80
+ async login(@Arg('input') input: LoginInput, @Ctx() ctx: TContext) {
81
+ const { t } = ctx;
82
+ const user = await authService.db.findUserByEmail(input.email);
83
+ if (!user) throw new Error(t('validation:user.notFound'));
84
+
85
+ if (user.isBanned) throw new Error(t('validation:user.banned'));
86
+
87
+ const isPasswordOk = await authService.verifyPassword(user.password!, input.password);
88
+ if (!isPasswordOk) throw new Error(t('validation:login.wrongPassword'));
89
+
90
+ const userAgent = decodeURI(ctx.req.get('user-agent') || '');
91
+ await ctx.req.session.create({
92
+ userId: user.id,
93
+ ip: ctx.req.ip,
94
+ userAgent: userAgent.slice(0, 500),
95
+ });
96
+
97
+ if (onLogin) await onLogin(user, ctx);
98
+ if (logTelegramBot) {
99
+ await logTelegramBot.sendError(
100
+ `${user.email || user.urlSlug} вошел(ла) на сайт`
101
+ );
102
+ }
103
+
104
+ return { ok: true };
105
+ }
106
+
107
+ @Authorized()
108
+ @Mutation(() => StatusPayload)
109
+ async logout(@Ctx() ctx: TContext) {
110
+ const { user } = ctx;
111
+ if (onLogout) await onLogout(user, ctx);
112
+ if (logTelegramBot) {
113
+ await logTelegramBot.sendError(
114
+ `${user && (user.email || user.urlSlug)} разлогинился(лась)`
115
+ );
116
+ }
117
+ await ctx.req.session.destroy();
118
+ return { ok: true };
119
+ }
120
+
121
+ @ValidateArgs(confirmEmailValidators, { arg: 'input', tKey: 'confirmEmail' })
122
+ @Mutation(() => StatusPayload)
123
+ async confirmEmail(@Arg('input') input: ConfirmEmailInput, @Ctx() ctx: TContext) {
124
+ const user = await authService.getUserByToken(CONFIRMATION_REDIS_PREFIX, input.token);
125
+ if (!user) throw new Error(ctx.t('validation:error.wrongCode'));
126
+
127
+ user.isEmailConfirmed = true;
128
+ await user.save();
129
+ await authService.removeTokenFromRedis(CONFIRMATION_REDIS_PREFIX, user, input.token);
130
+
131
+ const userAgent = decodeURI(ctx.req.get('user-agent') || '');
132
+ await ctx.req.session.create({
133
+ userId: user.id,
134
+ ip: ctx.req.ip,
135
+ userAgent: userAgent.slice(0, 500),
136
+ });
137
+
138
+ if (onEmailConfirmed) await onEmailConfirmed(user, ctx);
139
+ if (logTelegramBot) {
140
+ await logTelegramBot.sendError(`${user.email || user.urlSlug} подтвердил(а) email`);
141
+ }
142
+
143
+ return { ok: true };
144
+ }
145
+
146
+ @TransformArgs(forgotPasswordTransformers, { arg: 'input' })
147
+ @ValidateArgs(forgotPasswordValidators, { arg: 'input', tKey: 'forgot' })
148
+ @Mutation(() => SendTokenPayload)
149
+ async forgotPassword(@Arg('input') input: ForgotPasswordInput, @Ctx() ctx: TContext) {
150
+ const { t } = ctx;
151
+ const user = await authService.db.findUserByEmail(input.email);
152
+ if (!user) throw new Error(t('validation:forgot.errors.notRegistered'));
153
+
154
+ const res = await authService.sendLink(user, 'recovery');
155
+
156
+ if (logTelegramBot) {
157
+ await logTelegramBot.sendError(`${user.email || user.urlSlug} запросил(а) восстановление пароля`);
158
+ }
159
+
160
+ return {
161
+ confirmationLinkIsSent: res.ok,
162
+ limitExpiresAt: res.limitExpiresAt,
163
+ };
164
+ }
165
+
166
+ @TransformArgs(recoverPasswordTransformers, { arg: 'input' })
167
+ @ValidateArgs(recoverPasswordValidators, { arg: 'input', tKey: 'recover' })
168
+ @Mutation(() => StatusPayload)
169
+ async recoverPassword(@Arg('input') input: RecoverPasswordInput, @Ctx() ctx: TContext) {
170
+ const user = await authService.getUserByToken(RECOVERY_REDIS_PREFIX, input.token);
171
+ if (!user) throw new Error(ctx.t('validation:error.wrongCode'));
172
+
173
+ user.password = await authService.hashPassword(input.password);
174
+ user.isTempPassword = false;
175
+ await user.save();
176
+ await authService.removeTokenFromRedis(RECOVERY_REDIS_PREFIX, user, input.token);
177
+
178
+ const userAgent = decodeURI(ctx.req.get('user-agent') || '');
179
+ await ctx.req.session.create({
180
+ userId: user.id,
181
+ ip: ctx.req.ip,
182
+ userAgent: userAgent.slice(0, 500),
183
+ });
184
+
185
+ if (onPasswordRecovered) await onPasswordRecovered(user, ctx);
186
+ if (logTelegramBot) {
187
+ await logTelegramBot.sendError(`${user.email || user.urlSlug} восстановил(а) пароль`);
188
+ }
189
+
190
+ return { ok: true };
191
+ }
192
+ }
193
+
194
+ return AuthResolver;
195
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ Arg,
3
+ Authorized,
4
+ Ctx,
5
+ Mutation,
6
+ Resolver,
7
+ UnauthorizedError,
8
+ } from 'type-graphql';
9
+ import { StatusPayload } from '@os-team/graphql-utils';
10
+ import { TransformArgs } from '@os-team/graphql-transformers';
11
+ import { ValidateArgs } from '@os-team/graphql-validators';
12
+ import { ProfileAuthService } from '../services/ProfileAuthService';
13
+ import { ProfileContext } from '../types';
14
+ import {
15
+ UpdateEmailInput, updateEmailTransformers, updateEmailValidators,
16
+ UpdatePasswordInput, updatePasswordTransformers, updatePasswordValidators,
17
+ UpdateProfileInput, updateProfileTransformers, updateProfileValidators,
18
+ } from '../dto';
19
+
20
+ export interface ProfileResolverDependencies {
21
+ authService: ProfileAuthService;
22
+ userType: any;
23
+ }
24
+
25
+ export function createProfileResolver<TContext extends ProfileContext = ProfileContext>(deps: ProfileResolverDependencies) {
26
+ const { authService, userType } = deps;
27
+
28
+ @Resolver(() => userType)
29
+ class ProfileResolver {
30
+ @Authorized()
31
+ @TransformArgs(updateEmailTransformers, { arg: 'input' })
32
+ @ValidateArgs(updateEmailValidators, { arg: 'input', tKey: 'updateEmail' })
33
+ @Mutation(() => StatusPayload)
34
+ async updateEmail(@Arg('input') input: UpdateEmailInput, @Ctx() ctx: TContext) {
35
+ const { user, t } = ctx;
36
+ if (!user) throw new UnauthorizedError();
37
+
38
+ if (await authService.db.isEmailTaken(input.email, user.id)) {
39
+ throw new Error(t('validation:updateEmail.emailExists'));
40
+ }
41
+
42
+ user.email = input.email;
43
+ user.isEmailConfirmed = false;
44
+ await user.save();
45
+
46
+ return { ok: true };
47
+ }
48
+
49
+ @Authorized()
50
+ @TransformArgs(updatePasswordTransformers, { arg: 'input' })
51
+ @ValidateArgs(updatePasswordValidators, { arg: 'input', tKey: 'updatePassword' })
52
+ @Mutation(() => StatusPayload)
53
+ async updatePassword(@Arg('input') input: UpdatePasswordInput, @Ctx() ctx: TContext) {
54
+ const { user, t } = ctx;
55
+ if (!user) throw new UnauthorizedError();
56
+
57
+ const isOldPasswordOk = await authService.verifyPassword(user.password!, input.oldPassword);
58
+ if (!isOldPasswordOk) throw new Error(t('validation:updatePassword.wrongPassword'));
59
+
60
+ user.password = await authService.hashPassword(input.password);
61
+ user.isTempPassword = false;
62
+ await user.save();
63
+
64
+ return { ok: true };
65
+ }
66
+
67
+ @Authorized()
68
+ @TransformArgs(updateProfileTransformers, { arg: 'input' })
69
+ @ValidateArgs(updateProfileValidators, { arg: 'input', tKey: 'updateProfile' })
70
+ @Mutation(() => userType)
71
+ async updateProfile(@Arg('input') input: UpdateProfileInput, @Ctx() ctx: TContext) {
72
+ const { user } = ctx;
73
+ if (!user) throw new UnauthorizedError();
74
+
75
+ user.name = input.name;
76
+ await user.save();
77
+
78
+ return user;
79
+ }
80
+
81
+ @Authorized()
82
+ @Mutation(() => StatusPayload)
83
+ async toggleEmailNotification(@Ctx() ctx: TContext) {
84
+ const { user } = ctx;
85
+ if (!user) throw new UnauthorizedError();
86
+
87
+ user.isEmailNotificationEnabled = !user.isEmailNotificationEnabled;
88
+ await user.save();
89
+
90
+ return { ok: true };
91
+ }
92
+
93
+ @Authorized()
94
+ @Mutation(() => StatusPayload)
95
+ async toggleTelegramNotification(@Ctx() ctx: TContext) {
96
+ const { user } = ctx;
97
+ if (!user) throw new UnauthorizedError();
98
+
99
+ user.isTelegramNotificationEnabled = !user.isTelegramNotificationEnabled;
100
+ await user.save();
101
+
102
+ return { ok: true };
103
+ }
104
+ }
105
+
106
+ return ProfileResolver;
107
+ }
@@ -0,0 +1,122 @@
1
+ import argon2 from 'argon2';
2
+ import { Redis } from 'ioredis';
3
+ import { AuthUser, ProfileAuthConfig, ProfileDbAdapter } from '../types';
4
+ import { ProfileEmailService } from './ProfileEmailService';
5
+
6
+ export const CONFIRMATION_REDIS_PREFIX = 'conf';
7
+ export const RECOVERY_REDIS_PREFIX = 'rec';
8
+ export const TOKEN_REDIS_POSTFIX = 'token';
9
+ export const LAST_SENT_AT_REDIS_POSTFIX = 'lastSentAt';
10
+
11
+ export class ProfileAuthService {
12
+ public db: ProfileDbAdapter;
13
+
14
+ public email: ProfileEmailService;
15
+
16
+ public redis: Redis;
17
+
18
+ public config: ProfileAuthConfig;
19
+
20
+ constructor(
21
+ db: ProfileDbAdapter,
22
+ email: ProfileEmailService,
23
+ redis: Redis,
24
+ config: ProfileAuthConfig
25
+ ) {
26
+ this.db = db;
27
+ this.email = email;
28
+ this.redis = redis;
29
+ this.config = config;
30
+ }
31
+
32
+ async hashPassword(password: string): Promise<string> {
33
+ return argon2.hash(password, { type: argon2.argon2id });
34
+ }
35
+
36
+ async verifyPassword(hash: string, password: string): Promise<boolean> {
37
+ return argon2.verify(hash, password);
38
+ }
39
+
40
+ private getLimitMs() {
41
+ return this.config.resendEmailLimitSeconds * 1000;
42
+ }
43
+
44
+ async setTokenToRedis(
45
+ prefix: string,
46
+ user: AuthUser,
47
+ token: string,
48
+ ttlMinutes: number
49
+ ) {
50
+ const exSeconds = ttlMinutes * 60;
51
+
52
+ // token -> userId
53
+ await this.redis.setex(`${prefix}:${token}`, exSeconds, user.id.toString());
54
+
55
+ // userId -> token
56
+ await this.redis.setex(
57
+ `${prefix}:${user.id}:${TOKEN_REDIS_POSTFIX}`,
58
+ exSeconds,
59
+ token
60
+ );
61
+
62
+ // lastSentAt
63
+ await this.redis.psetex(
64
+ `${prefix}:${user.id}:${LAST_SENT_AT_REDIS_POSTFIX}`,
65
+ this.getLimitMs(),
66
+ Date.now().toString()
67
+ );
68
+ }
69
+
70
+ async sendLink(user: AuthUser, type: 'confirmation' | 'recovery') {
71
+ const prefix =
72
+ type === 'confirmation'
73
+ ? CONFIRMATION_REDIS_PREFIX
74
+ : RECOVERY_REDIS_PREFIX;
75
+ const ttlMinutes =
76
+ type === 'confirmation'
77
+ ? this.config.confirmationTokenLifetimeMinutes
78
+ : this.config.recoveryTokenLifetimeMinutes;
79
+
80
+ // Check limit
81
+ const lastSentAtStr = await this.redis.get(
82
+ `${prefix}:${user.id}:${LAST_SENT_AT_REDIS_POSTFIX}`
83
+ );
84
+ const lastSentAt = Number(lastSentAtStr) || 0;
85
+ const expiresAt = lastSentAt + this.getLimitMs();
86
+ const expiresIn = expiresAt - Date.now();
87
+
88
+ if (expiresIn > 0) return { ok: false, limitExpiresAt: expiresAt };
89
+
90
+ // Generate token (6 digits as in original code)
91
+ let token = '';
92
+ for (let i = 0; i < 6; i++) {
93
+ token += Math.floor(Math.random() * 10);
94
+ }
95
+
96
+ await this.setTokenToRedis(prefix, user, token, ttlMinutes);
97
+ const ok = await this.email.sendToken(user, type, token);
98
+
99
+ return { ok, limitExpiresAt: Date.now() + this.getLimitMs() };
100
+ }
101
+
102
+ async getUserByToken(
103
+ prefix: string,
104
+ token: string
105
+ ): Promise<AuthUser | null> {
106
+ const userIdStr = await this.redis.get(`${prefix}:${token}`);
107
+ if (!userIdStr) return null;
108
+
109
+ const userId = Number(userIdStr);
110
+ if (isNaN(userId)) return null;
111
+
112
+ return this.db.findUserById(userId);
113
+ }
114
+
115
+ async removeTokenFromRedis(prefix: string, user: AuthUser, token: string) {
116
+ await this.redis.del(
117
+ `${prefix}:${token}`,
118
+ `${prefix}:${user.id}:${TOKEN_REDIS_POSTFIX}`,
119
+ `${prefix}:${user.id}:${LAST_SENT_AT_REDIS_POSTFIX}`
120
+ );
121
+ }
122
+ }
@@ -0,0 +1,158 @@
1
+ import AwsSes from '@os-team/aws-ses';
2
+ import pug from 'pug';
3
+ import path from 'path';
4
+ import {
5
+ AuthUser,
6
+ EmailConfig,
7
+ ProfileEmailTemplate,
8
+ ProfileLocales,
9
+ } from '../types';
10
+
11
+ export type EmailType = 'auth' | 'activity' | 'info' | 'admin' | 'other';
12
+
13
+ export class ProfileEmailService {
14
+ private ses: AwsSes;
15
+
16
+ private config: EmailConfig;
17
+
18
+ private locales: ProfileLocales;
19
+
20
+ constructor(config: EmailConfig, locales: ProfileLocales) {
21
+ this.ses = new AwsSes({ region: 'eu-west-1' });
22
+ this.config = config;
23
+ this.locales = locales;
24
+ }
25
+
26
+ private getWebsiteUrl() {
27
+ return this.config.websiteUrl || `https://${this.config.domain}`;
28
+ }
29
+
30
+ private render(templateName: string, vars: any) {
31
+ const templatePath = path.resolve(
32
+ this.config.templateDir,
33
+ `${templateName}.pug`
34
+ );
35
+ return pug.renderFile(templatePath, {
36
+ domain: this.config.domain,
37
+ websiteUrl: this.getWebsiteUrl(),
38
+ primaryBrandColor: this.config.primaryBrandColor,
39
+ logoUrl: this.config.logoUrl || '',
40
+ ...vars,
41
+ });
42
+ }
43
+
44
+ private getFrom(type: EmailType) {
45
+ const { domain } = this.config;
46
+ const username = this.config.fromEmailUsername;
47
+
48
+ switch (type) {
49
+ case 'auth':
50
+ return `${domain} <${username}@auth.${domain}>`;
51
+ case 'activity':
52
+ return `${domain} <${username}@a.${domain}>`;
53
+ case 'info':
54
+ return `${domain} <${username}@i.${domain}>`;
55
+ case 'admin':
56
+ return `${domain} <${username}@admin.${domain}>`;
57
+ default:
58
+ return `${domain} <${username}@o.${domain}>`;
59
+ }
60
+ }
61
+
62
+ async send(
63
+ to: string,
64
+ subject: string,
65
+ template: string,
66
+ vars: any,
67
+ type: EmailType = 'other'
68
+ ) {
69
+ const html = this.render(template, vars);
70
+ const from = this.getFrom(type);
71
+
72
+ return this.ses.send({
73
+ Destination: { ToAddresses: [to] },
74
+ Message: {
75
+ Subject: { Data: subject, Charset: 'UTF-8' },
76
+ Body: { Html: { Data: html, Charset: 'UTF-8' } },
77
+ },
78
+ Source: from,
79
+ });
80
+ }
81
+
82
+ async sendToken(
83
+ user: AuthUser,
84
+ type: 'confirmation' | 'recovery',
85
+ token: string
86
+ ) {
87
+ if (!user.email) return false;
88
+
89
+ const isConfirm = type === 'confirmation';
90
+ const emailLocales = isConfirm
91
+ ? this.locales.email.confirmEmail
92
+ : this.locales.email.forgotPassword;
93
+
94
+ const template = isConfirm
95
+ ? ProfileEmailTemplate.CONFIRM_EMAIL
96
+ : ProfileEmailTemplate.FORGOT_PASSWORD;
97
+
98
+ return this.send(
99
+ user.email,
100
+ emailLocales.subject,
101
+ template,
102
+ {
103
+ header: emailLocales.header,
104
+ text: emailLocales.text,
105
+ linkTitle: emailLocales.linkTitle,
106
+ linkHref: `${this.getWebsiteUrl()}/${type}?token=${token}&email=${
107
+ user.email
108
+ }`,
109
+ confirmationToken: token,
110
+ },
111
+ 'auth'
112
+ );
113
+ }
114
+
115
+ async sendTemporaryPassword(user: AuthUser, tempPassword: string) {
116
+ if (!user.email) return false;
117
+
118
+ const emailLocales = this.locales.email.tempPassword;
119
+
120
+ return this.send(
121
+ user.email,
122
+ emailLocales.subject,
123
+ ProfileEmailTemplate.TEMP_PASSWORD,
124
+ {
125
+ header: emailLocales.header.replace('{{tempPassword}}', tempPassword),
126
+ text: emailLocales.text,
127
+ linkTitle: emailLocales.linkTitle,
128
+ linkHref: this.getWebsiteUrl(),
129
+ },
130
+ 'auth'
131
+ );
132
+ }
133
+
134
+ /* Generic method for any custom project emails */
135
+ async sendGenericEmail(
136
+ user: AuthUser,
137
+ subject: string,
138
+ text: string,
139
+ type: EmailType = 'info',
140
+ vars: any = {}
141
+ ) {
142
+ if (!user.email) return false;
143
+
144
+ return this.send(
145
+ user.email,
146
+ subject,
147
+ ProfileEmailTemplate.GENERIC_NOTIFICATION,
148
+ {
149
+ header: subject,
150
+ text,
151
+ linkTitle: 'Go to Website',
152
+ linkHref: this.getWebsiteUrl(),
153
+ ...vars,
154
+ },
155
+ type
156
+ );
157
+ }
158
+ }