Skip to content
This repository was archived by the owner on Oct 5, 2023. It is now read-only.

Commit b0de9b2

Browse files
As a user, I want to change/set password by OTP (#474)
* Fix url classwork detail * Set Password * Reset password * UI * Test Mail * Handle Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
1 parent b61acb5 commit b0de9b2

File tree

23 files changed

+1030
-72
lines changed

23 files changed

+1030
-72
lines changed

packages/server/schema.gql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ type Account implements BaseModel {
4545
username: String!
4646
email: String!
4747
displayName: String
48+
otp: String
49+
otpExpired: DateTime
4850
status: AccountStatus!
4951
roles: [String!]!
5052
availability: AccountAvailability!
@@ -315,6 +317,8 @@ type Mutation {
315317
createOrgAccount(input: CreateAccountInput!): Account!
316318
updateAccount(updateInput: UpdateAccountInput!, id: ID!): Account!
317319
updateAccountStatus(status: String!, id: ID!): Account!
320+
setPassword(otp: String!, password: String!, usernameOrEmail: String!): Account!
321+
callOTP(type: String!, usernameOrEmail: String!): Account!
318322
signIn(
319323
password: String!
320324

packages/server/src/core/utils/string.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,14 @@ export const stringWithoutSpecialCharacters = (
2020
/^[a-zA-ZÀÁÂÃÈÉÊÌÍÒÓÔÕÙÚĂĐĨŨƠàáâãèéêìíòóôõùúăđĩũơƯĂưăếÝ\s]+$/
2121
return regex.test(inputString)
2222
}
23+
24+
export const generateString = (length: number): string => {
25+
const characters =
26+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
27+
let result = ''
28+
for (let i = 0; i < length; i += 1) {
29+
result += characters.charAt(Math.floor(Math.random() * characters.length))
30+
}
31+
32+
return result
33+
}

packages/server/src/modules/academic/academic.service.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Connection } from 'mongoose'
44
import { Publication } from 'core'
55
import { objectId } from 'core/utils/db'
66
import { createTestingModule, initTestDb } from 'core/utils/testing'
7+
import { AccountStatus } from 'modules/account/models/Account'
78
import { ClassworkService } from 'modules/classwork/classwork.service'
89
import { AvgGradeOfClassworkByCourseOptionInput } from 'modules/classwork/classwork.type'
910
import { OrgOfficeService } from 'modules/orgOffice/orgOffice.service'
@@ -2229,6 +2230,7 @@ describe('academic.service', () => {
22292230
username: 'thanhthanh',
22302231
roles: ['student'],
22312232
displayName: 'Huynh Thanh Thanh',
2233+
status: AccountStatus.Active,
22322234
})
22332235

22342236
const accStudent2 = await accountService.createAccount({
@@ -2238,22 +2240,27 @@ describe('academic.service', () => {
22382240
username: 'thanhthanh2',
22392241
roles: ['student'],
22402242
displayName: 'Huynh Thanh Thanh',
2243+
status: AccountStatus.Active,
22412244
})
22422245

22432246
const accLecturer = await accountService.createAccount({
22442247
roles: ['lecturer'],
22452248
2249+
password: '123456',
22462250
username: 'thanhcanh',
22472251
orgId: org.id,
22482252
displayName: 'Huynh Thanh Canh',
2253+
status: AccountStatus.Active,
22492254
})
22502255

22512256
const accAdmin = await accountService.createAccount({
22522257
roles: ['admin'],
22532258
username: 'thanhcanh123',
2259+
password: '123456',
22542260
orgId: org.id,
22552261
22562262
displayName: 'Huynh Thanh Canh Thanh',
2263+
status: AccountStatus.Active,
22572264
})
22582265

22592266
const academicSubject = await academicService.createAcademicSubject({
@@ -2322,7 +2329,7 @@ describe('academic.service', () => {
23222329
)
23232330

23242331
await authService.signIn({
2325-
password: '[email protected]',
2332+
password: '123456',
23262333
usernameOrEmail: '[email protected]',
23272334
orgNamespace: 'kmin-edu',
23282335
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export const ACCOUNT_PWD_MIN = 8
2+
export const OTP_TIME = 15

packages/server/src/modules/account/account.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { forwardRef, Global, Module } from '@nestjs/common'
22
import { TypegooseModule } from 'nestjs-typegoose'
33

44
import { AuthModule } from 'modules/auth/auth.module'
5+
import { MailModule } from 'modules/mail/mail.module'
56

67
import { AccountResolver } from './account.resolver'
78
import { AccountService } from './account.service'
@@ -10,6 +11,7 @@ import { Account } from './models/Account'
1011
@Global()
1112
@Module({
1213
imports: [
14+
MailModule,
1315
forwardRef(() => AuthModule),
1416
TypegooseModule.forFeature([Account]),
1517
],

packages/server/src/modules/account/account.resolver.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,26 @@ export class AccountResolver {
121121
)
122122
}
123123

124+
@Mutation((_returns) => Account)
125+
@UsePipes(ValidationPipe)
126+
async setPassword(
127+
@Args('usernameOrEmail', { type: () => String }) usernameOrEmail: string,
128+
@Args('password', { type: () => String }) password: string,
129+
@Args('otp', { type: () => String }) otp: string,
130+
): Promise<Account> {
131+
return this.accountService.setPassword(usernameOrEmail, password, otp)
132+
}
133+
134+
@Mutation((_returns) => Account)
135+
@UsePipes(ValidationPipe)
136+
async callOTP(
137+
@Args('usernameOrEmail', { type: () => String }) usernameOrEmail: string,
138+
@Args('type', { type: () => String })
139+
type: 'ACTIVE_ACCOUNT' | 'RESET_PASSWORD',
140+
): Promise<Account> {
141+
return this.accountService.callOTP(usernameOrEmail, type)
142+
}
143+
124144
@ResolveField((_returns) => AccountAvailability)
125145
availability(@Parent() account: Account): AccountAvailability {
126146
if (!account.lastActivityAt) {

packages/server/src/modules/account/account.service.spec.ts

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -116,28 +116,7 @@ describe('account.service', () => {
116116
).rejects.toThrow('Org ID is invalid')
117117
})
118118

119-
it(`sets default password as email`, async () => {
120-
expect.assertions(1)
121-
122-
jest
123-
.spyOn(accountService['orgService'], 'validateOrgId')
124-
.mockResolvedValueOnce(true as never)
125-
126-
const testCreateAccountServiceInput: CreateAccountServiceInput = {
127-
...createAccountServiceInput,
128-
password: '',
129-
}
130-
131-
const testAccount = await accountService.createAccount(
132-
testCreateAccountServiceInput,
133-
)
134-
135-
await expect(
136-
compareSync(testCreateAccountServiceInput.email, testAccount.password),
137-
).toBe(true)
138-
})
139-
140-
it(`sets default status as ACTIVE`, async () => {
119+
it(`sets default status as Pending`, async () => {
141120
expect.assertions(1)
142121

143122
jest
@@ -148,7 +127,7 @@ describe('account.service', () => {
148127
createAccountServiceInput,
149128
)
150129

151-
await expect(testAccount.status).toBe(AccountStatus.Active)
130+
await expect(testAccount.status).toBe(AccountStatus.Pending)
152131
})
153132

154133
it(`replaces duplicated spaces in displayName by single spaces`, async () => {
@@ -220,7 +199,7 @@ describe('account.service', () => {
220199
expect(account).toMatchObject({
221200
222201
lastActivityAt: null,
223-
status: 'Active',
202+
status: 'Pending',
224203
username: 'duongdev',
225204
})
226205
expect(account.orgId).toBeDefined()

packages/server/src/modules/account/account.service.ts

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable no-process-env */
12
import { forwardRef, Inject } from '@nestjs/common'
23
import { DocumentType, ReturnModelType } from '@typegoose/typegoose'
34
import * as bcrypt from 'bcrypt'
@@ -7,14 +8,17 @@ import { ForbiddenError } from 'type-graphql'
78
import { Service, InjectModel, Logger } from 'core'
89
import { isObjectId } from 'core/utils/db'
910
import {
11+
generateString,
1012
removeExtraSpaces,
1113
stringWithoutSpecialCharacters,
1214
} from 'core/utils/string'
1315
import { AuthService } from 'modules/auth/auth.service'
1416
import { OrgRoleName, Permission } from 'modules/auth/models'
17+
import { MailService } from 'modules/mail/mail.service'
1518
import { OrgService } from 'modules/org/org.service'
1619
import { ANY, Nullable } from 'types'
1720

21+
import { OTP_TIME } from './account.const'
1822
import { CreateAccountServiceInput } from './account.type'
1923
import { Account, AccountStatus } from './models/Account'
2024

@@ -29,6 +33,8 @@ export class AccountService {
2933
private readonly authService: AuthService,
3034
@Inject(forwardRef(() => OrgService))
3135
private readonly orgService: OrgService,
36+
@Inject(forwardRef(() => MailService))
37+
private readonly mailService: MailService,
3238
) {}
3339

3440
async createAccount(
@@ -62,20 +68,29 @@ export class AccountService {
6268
throw new Error('displayName contains invalid characters')
6369
}
6470

71+
const otp = generateString(20)
72+
73+
const otpExpired = new Date()
74+
otpExpired.setMinutes(otpExpired.getMinutes() + OTP_TIME)
75+
6576
const account = await this.accountModel.create({
6677
username: accountInput.username,
6778
email: accountInput.email,
68-
password: bcrypt.hashSync(
69-
accountInput.password || accountInput.email,
70-
10,
71-
),
79+
password: bcrypt.hashSync(accountInput.password || '', 10),
80+
otp,
81+
otpExpired,
7282
orgId: accountInput.orgId,
7383
createdBy: accountInput.createdByAccountId,
7484
status: accountInput.status,
7585
roles: uniq(accountInput.roles),
7686
displayName: removeExtraSpaces(accountInput.displayName),
7787
})
7888

89+
if (process.env.NODE_ENV !== 'test') {
90+
this.mailService
91+
.sendOTP(account, 'ACTIVE_ACCOUNT')
92+
.then(() => this.logger.log('Send mail success!'))
93+
}
7994
this.logger.log(`[${this.createAccount.name}] Created account successfully`)
8095
this.logger.verbose(account.toObject())
8196

@@ -366,4 +381,75 @@ export class AccountService {
366381

367382
return updateAccount
368383
}
384+
385+
async setPassword(
386+
usernameOrEmail: string,
387+
password: string,
388+
otp: string,
389+
): Promise<DocumentType<Account>> {
390+
const account = await this.accountModel.findOne({
391+
$or: [{ email: usernameOrEmail }, { username: usernameOrEmail }],
392+
})
393+
394+
if (!account) {
395+
throw new Error('Account not found')
396+
}
397+
const current = new Date()
398+
if (account.status === AccountStatus.Deactivated) {
399+
throw new Error('Account has been deactivated')
400+
}
401+
if (account.otp !== otp) {
402+
throw new Error('OTP invalid')
403+
}
404+
if (account.otpExpired.getTime() < current.getTime()) {
405+
throw new Error('OTP expired')
406+
}
407+
408+
if (account.status === AccountStatus.Pending) {
409+
account.status = AccountStatus.Active
410+
}
411+
account.otp = ''
412+
account.otpExpired = new Date(current.setHours(current.getHours() - 1))
413+
account.password = bcrypt.hashSync(password, 10)
414+
const afterAccount = await account.save()
415+
416+
return afterAccount
417+
}
418+
419+
async callOTP(
420+
usernameOrEmail: string,
421+
type: 'ACTIVE_ACCOUNT' | 'RESET_PASSWORD',
422+
): Promise<DocumentType<Account>> {
423+
const account = await this.accountModel.findOne({
424+
$or: [{ email: usernameOrEmail }, { username: usernameOrEmail }],
425+
})
426+
427+
if (!account) {
428+
throw new Error('Account not found')
429+
}
430+
if (account.status === AccountStatus.Deactivated) {
431+
throw new Error('Account has been deactivated')
432+
}
433+
const current = new Date()
434+
if (account.otpExpired.getTime() > current.getTime()) {
435+
throw new Error(
436+
`Don't spam, please try again after ${account.otpExpired.getHours()}:${account.otpExpired.getMinutes()}`,
437+
)
438+
}
439+
const otp = generateString(20)
440+
const otpExpired = new Date()
441+
otpExpired.setMinutes(otpExpired.getMinutes() + OTP_TIME)
442+
account.otp = otp
443+
account.otpExpired = otpExpired
444+
445+
const afterAccount = await account.save()
446+
447+
if (process.env.NODE_ENV !== 'test') {
448+
this.mailService
449+
.sendOTP(afterAccount, type)
450+
.then(() => this.logger.log('Send mail success!'))
451+
}
452+
453+
return afterAccount
454+
}
369455
}

packages/server/src/modules/account/models/Account.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,20 @@ export class Account extends BaseModel {
5050
@prop({ required: true })
5151
password: string
5252

53+
@Field({ nullable: true })
54+
@prop({ nullable: true })
55+
otp: string
56+
57+
@Field({ nullable: true })
58+
@prop({ nullable: true })
59+
otpExpired: Date
60+
5361
@Field((_type) => AccountStatus)
5462
@prop({
5563
enum: AccountStatus,
5664
type: String,
5765
index: true,
58-
default: AccountStatus.Active,
66+
default: AccountStatus.Pending,
5967
})
6068
status: AccountStatus
6169

0 commit comments

Comments
 (0)