From 4517900bd0cd62f9e232bde53a4a800e0cba6e7d Mon Sep 17 00:00:00 2001 From: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Date: Wed, 20 Mar 2024 12:29:28 -0600 Subject: [PATCH] feat: pwdless create account flow (#3964) --- api/prisma/seed-dev.ts | 2 +- api/src/controllers/auth.controller.ts | 18 +- api/src/controllers/user.controller.ts | 25 +- api/src/services/auth.service.ts | 104 ++---- api/src/services/user.service.ts | 108 +++++- api/src/utilities/generate-single-use-code.ts | 10 + api/test/integration/auth.e2e-spec.ts | 109 +----- api/test/integration/user.e2e-spec.ts | 111 ++++++ api/test/unit/services/auth.service.spec.ts | 225 +---------- api/test/unit/services/user.service.spec.ts | 262 ++++++++++++- .../generate-single-use-code.spec.ts | 22 ++ shared-helpers/src/auth/AuthContext.ts | 2 +- shared-helpers/src/locales/general.json | 5 + shared-helpers/src/types/backend-swagger.ts | 348 +++++++++--------- .../src/components/account/SignUpBenefits.tsx | 8 +- .../src/pages/applications/contact/name.tsx | 4 +- sites/public/src/pages/create-account.tsx | 35 +- sites/public/src/pages/verify.tsx | 11 +- .../public/styles/create-account.module.scss | 8 + 19 files changed, 767 insertions(+), 650 deletions(-) create mode 100644 api/src/utilities/generate-single-use-code.ts create mode 100644 api/test/unit/utilities/generate-single-use-code.spec.ts diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 73139b5bb9..1ef6491b50 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -47,7 +47,7 @@ export const devSeeding = async ( const jurisdiction = await prismaClient.jurisdictions.create({ data: { ...jurisdictionFactory(jurisdictionName), - allowSingleUseCodeLogin: true, + allowSingleUseCodeLogin: false, }, }); await prismaClient.userAccounts.create({ diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index 966e8b4848..497d01e4c8 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -64,7 +64,10 @@ export class AuthController { @Request() req: ExpressRequest, @Response({ passthrough: true }) res: ExpressResponse, ): Promise { - return await this.authService.setCredentials(res, mapTo(User, req['user'])); + return await this.authService.confirmAndSetCredentials( + mapTo(User, req['user']), + res, + ); } @Get('logout') @@ -90,19 +93,6 @@ export class AuthController { return await this.authService.requestMfaCode(dto); } - @Post('request-single-use-code') - @ApiOperation({ - summary: 'Request single use code', - operationId: 'requestSingleUseCode', - }) - @ApiOkResponse({ type: SuccessDTO }) - async requestSingleUseCode( - @Request() req: ExpressRequest, - @Body() dto: RequestSingleUseCode, - ): Promise { - return await this.authService.requestSingleUseCode(dto, req); - } - @Get('requestNewToken') @ApiOperation({ summary: 'Requests a new token given a refresh token', diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index 3afa5259ec..4b371086da 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -49,6 +49,7 @@ import { PermissionTypeDecorator } from '../decorators/permission-type.decorator import { UserFilterParams } from '../dtos/users/user-filter-params.dto'; import { UserCsvExporterService } from '../services/user-csv-export.service'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('user') @@ -170,13 +171,11 @@ export class UserController { @Body() dto: UserCreate, @Query() queryParams: UserCreateParams, ): Promise { - const jurisdictionName = req.headers['jurisdictionname'] || ''; return await this.userService.create( dto, false, queryParams.noWelcomeEmail !== true, - mapTo(User, req['user']), - jurisdictionName as string, + req, ); } @@ -189,12 +188,20 @@ export class UserController { @Body() dto: UserInvite, @Request() req: ExpressRequest, ): Promise { - return await this.userService.create( - dto, - true, - undefined, - mapTo(User, req['user']), - ); + return await this.userService.create(dto, true, undefined, req); + } + + @Post('request-single-use-code') + @ApiOperation({ + summary: 'Request single use code', + operationId: 'requestSingleUseCode', + }) + @ApiOkResponse({ type: SuccessDTO }) + async requestSingleUseCode( + @Request() req: ExpressRequest, + @Body() dto: RequestSingleUseCode, + ): Promise { + return await this.userService.requestSingleUseCode(dto, req); } @Post('resend-confirmation') diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index 2b98b2c342..4e5a995ad7 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -4,9 +4,8 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; -import { CookieOptions, Request, Response } from 'express'; +import { CookieOptions, Response } from 'express'; import { sign, verify } from 'jsonwebtoken'; -import { randomInt } from 'crypto'; import { Prisma } from '@prisma/client'; import { UpdatePassword } from '../dtos/auth/update-password.dto'; import { MfaType } from '../enums/mfa/mfa-type-enum'; @@ -19,11 +18,10 @@ import { PrismaService } from './prisma.service'; import { UserService } from './user.service'; import { IdDTO } from '../dtos/shared/id.dto'; import { mapTo } from '../utilities/mapTo'; +import { generateSingleUseCode } from '../utilities/generate-single-use-code'; import { Confirm } from '../dtos/auth/confirm.dto'; import { SmsService } from './sms.service'; import { EmailService } from './email.service'; -import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; -import { OrderByEnum } from '../enums/shared/order-by-enum'; // since our local env doesn't have an https cert we can't be secure. Hosted envs should be secure const secure = process.env.NODE_ENV !== 'development'; @@ -220,7 +218,7 @@ export class AuthService { } } - const singleUseCode = this.generateSingleUseCode(); + const singleUseCode = generateSingleUseCode(); await this.prisma.userAccounts.update({ data: { singleUseCode, @@ -246,76 +244,6 @@ export class AuthService { }; } - /** - * - * @param dto the incoming request with the email - * @returns a SuccessDTO always, and if the user exists it will send a code to the requester - */ - async requestSingleUseCode( - dto: RequestSingleUseCode, - req: Request, - ): Promise { - const user = await this.prisma.userAccounts.findFirst({ - where: { email: dto.email }, - include: { - jurisdictions: true, - }, - }); - if (!user) { - return { success: true }; - } - - const jurisName = req?.headers?.jurisdictionname; - if (!jurisName) { - throw new BadRequestException( - 'jurisdictionname is missing from the request headers', - ); - } - - const juris = await this.prisma.jurisdictions.findFirst({ - select: { - id: true, - allowSingleUseCodeLogin: true, - }, - where: { - name: jurisName as string, - }, - orderBy: { - allowSingleUseCodeLogin: OrderByEnum.DESC, - }, - }); - - if (!juris) { - throw new BadRequestException( - `Jurisidiction ${jurisName} does not exists`, - ); - } - - if (!juris.allowSingleUseCodeLogin) { - throw new BadRequestException( - `Single use code login is not setup for ${jurisName}`, - ); - } - - const singleUseCode = this.generateSingleUseCode(); - await this.prisma.userAccounts.update({ - data: { - singleUseCode, - singleUseCodeUpdatedAt: new Date(), - }, - where: { - id: user.id, - }, - }); - - await this.emailsService.sendSingleUseCode( - mapTo(User, user), - singleUseCode, - ); - - return { success: true }; - } - /* updates a user's password and logs them in */ @@ -399,14 +327,26 @@ export class AuthService { } /* - generates a numeric mfa code + confirms a user if using pwdless */ - generateSingleUseCode() { - let out = ''; - const characters = '0123456789'; - for (let i = 0; i < Number(process.env.MFA_CODE_LENGTH); i++) { - out += characters.charAt(randomInt(characters.length)); + async confirmAndSetCredentials( + user: User, + res: Response, + ): Promise { + if (!user.confirmedAt) { + const data: Prisma.UserAccountsUpdateInput = { + confirmedAt: new Date(), + confirmationToken: null, + }; + + await this.prisma.userAccounts.update({ + data, + where: { + id: user.id, + }, + }); } - return out; + + return await this.setCredentials(res, user); } } diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index 98ef7e61ca..734fe809c4 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -11,6 +11,8 @@ import dayjs from 'dayjs'; import advancedFormat from 'dayjs/plugin/advancedFormat'; import crypto from 'crypto'; import { verify, sign } from 'jsonwebtoken'; +import { Request } from 'express'; + import { PrismaService } from './prisma.service'; import { User } from '../dtos/users/user.dto'; import { mapTo } from '../utilities/mapTo'; @@ -37,6 +39,8 @@ import { permissionActions } from '../enums/permissions/permission-actions-enum' import { buildWhereClause } from '../utilities/build-user-where'; import { getPublicEmailURL } from '../utilities/get-public-email-url'; import { UserRole } from '../dtos/users/user-role.dto'; +import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; +import { generateSingleUseCode } from '../utilities/generate-single-use-code'; /* this is the service for users @@ -479,9 +483,11 @@ export class UserService { dto: UserCreate | UserInvite, forPartners: boolean, sendWelcomeEmail = false, - requestingUser: User, - jurisdictionName?: string, + req: Request, ): Promise { + const requestingUser = mapTo(User, req['user']); + const jurisdictionName = (req.headers['jurisdictionname'] as string) || ''; + if ( this.containsInvalidCharacters(dto.firstName) || this.containsInvalidCharacters(dto.lastName) @@ -646,16 +652,27 @@ export class UserService { // Public user that needs email if (!forPartners && sendWelcomeEmail) { - const confirmationUrl = this.getPublicConfirmationUrl( - dto.appUrl, - confirmationToken, - ); - this.emailService.welcome( - jurisdictionName, - mapTo(User, newUser), - dto.appUrl, - confirmationUrl, - ); + const fullJurisdiction = await this.prisma.jurisdictions.findFirst({ + where: { + name: jurisdictionName as string, + }, + }); + + if (fullJurisdiction?.allowSingleUseCodeLogin) { + this.requestSingleUseCode(dto, req); + } else { + const confirmationUrl = this.getPublicConfirmationUrl( + dto.appUrl, + confirmationToken, + ); + this.emailService.welcome( + jurisdictionName, + mapTo(User, newUser), + dto.appUrl, + confirmationUrl, + ); + } + // Partner user that is given access to an additional jurisdiction } else if ( forPartners && @@ -876,4 +893,71 @@ export class UserService { return false; } + + /** + * + * @param dto the incoming request with the email + * @returns a SuccessDTO always, and if the user exists it will send a code to the requester + */ + async requestSingleUseCode( + dto: RequestSingleUseCode, + req: Request, + ): Promise { + const user = await this.prisma.userAccounts.findFirst({ + where: { email: dto.email }, + include: { + jurisdictions: true, + }, + }); + if (!user) { + return { success: true }; + } + + const jurisdictionName = req?.headers?.jurisdictionname; + if (!jurisdictionName) { + throw new BadRequestException( + 'jurisdictionname is missing from the request headers', + ); + } + + const juris = await this.prisma.jurisdictions.findFirst({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: jurisdictionName as string, + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + + if (!juris) { + throw new BadRequestException( + `Jurisidiction ${jurisdictionName} does not exists`, + ); + } + + if (!juris.allowSingleUseCodeLogin) { + throw new BadRequestException( + `Single use code login is not setup for ${jurisdictionName}`, + ); + } + + const singleUseCode = generateSingleUseCode(); + await this.prisma.userAccounts.update({ + data: { + singleUseCode, + singleUseCodeUpdatedAt: new Date(), + }, + where: { + id: user.id, + }, + }); + + await this.emailService.sendSingleUseCode(mapTo(User, user), singleUseCode); + + return { success: true }; + } } diff --git a/api/src/utilities/generate-single-use-code.ts b/api/src/utilities/generate-single-use-code.ts new file mode 100644 index 0000000000..0619d0a76a --- /dev/null +++ b/api/src/utilities/generate-single-use-code.ts @@ -0,0 +1,10 @@ +import { randomInt } from 'crypto'; + +export const generateSingleUseCode = () => { + let out = ''; + const characters = '0123456789'; + for (let i = 0; i < Number(process.env.MFA_CODE_LENGTH); i++) { + out += characters.charAt(randomInt(characters.length)); + } + return out; +}; diff --git a/api/test/integration/auth.e2e-spec.ts b/api/test/integration/auth.e2e-spec.ts index 5810e2e9ad..fe04098905 100644 --- a/api/test/integration/auth.e2e-spec.ts +++ b/api/test/integration/auth.e2e-spec.ts @@ -18,7 +18,7 @@ import { EmailService } from '../../src/services/email.service'; import { RequestMfaCode } from '../../src/dtos/mfa/request-mfa-code.dto'; import { UpdatePassword } from '../../src/dtos/auth/update-password.dto'; import { Confirm } from '../../src/dtos/auth/confirm.dto'; -import { LoginViaSingleUseCode } from 'src/dtos/auth/login-single-use-code.dto'; +import { LoginViaSingleUseCode } from '../../src/dtos/auth/login-single-use-code.dto'; describe('Auth Controller Tests', () => { let app: INestApplication; @@ -349,113 +349,6 @@ describe('Auth Controller Tests', () => { .expect(200); }); - it('should request single use code successfully', async () => { - const storedUser = await prisma.userAccounts.create({ - data: await userFactory({ - roles: { isAdmin: true }, - mfaEnabled: true, - confirmedAt: new Date(), - phoneNumber: '111-111-1111', - phoneNumberVerified: true, - }), - }); - - const jurisdiction = await prisma.jurisdictions.create({ - data: { - name: 'single_use_code_1', - allowSingleUseCodeLogin: true, - rentalAssistanceDefault: 'test', - }, - }); - emailService.sendSingleUseCode = jest.fn(); - - const res = await request(app.getHttpServer()) - .post('/auth/request-single-use-code') - .send({ - email: storedUser.email, - } as RequestMfaCode) - .set({ jurisdictionname: jurisdiction.name }) - .expect(201); - - expect(res.body).toEqual({ success: true }); - - expect(emailService.sendSingleUseCode).toHaveBeenCalled(); - - const user = await prisma.userAccounts.findUnique({ - where: { - id: storedUser.id, - }, - }); - - expect(user.singleUseCode).not.toBeNull(); - expect(user.singleUseCodeUpdatedAt).not.toBeNull(); - }); - - it('should request single use code, but jurisdiction does not allow', async () => { - const storedUser = await prisma.userAccounts.create({ - data: await userFactory({ - roles: { isAdmin: true }, - mfaEnabled: true, - confirmedAt: new Date(), - phoneNumber: '111-111-1111', - phoneNumberVerified: true, - }), - }); - - const jurisdiction = await prisma.jurisdictions.create({ - data: { - name: 'single_use_code_2', - allowSingleUseCodeLogin: false, - rentalAssistanceDefault: 'test', - }, - }); - emailService.sendSingleUseCode = jest.fn(); - - const res = await request(app.getHttpServer()) - .post('/auth/request-single-use-code') - .send({ - email: storedUser.email, - } as RequestMfaCode) - .set({ jurisdictionname: jurisdiction.name }) - .expect(400); - - expect(res.body.message).toEqual( - 'Single use code login is not setup for single_use_code_2', - ); - - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - - const user = await prisma.userAccounts.findUnique({ - where: { - id: storedUser.id, - }, - }); - - expect(user.singleUseCode).toBeNull(); - }); - - it('should request single use code, but user does not exist', async () => { - const jurisdiction = await prisma.jurisdictions.create({ - data: { - name: 'single_use_code_3', - allowSingleUseCodeLogin: true, - rentalAssistanceDefault: 'test', - }, - }); - emailService.sendSingleUseCode = jest.fn(); - - const res = await request(app.getHttpServer()) - .post('/auth/request-single-use-code') - .send({ - email: 'thisEmailDoesNotExist@exygy.com', - } as RequestMfaCode) - .set({ jurisdictionname: jurisdiction.name }) - .expect(201); - expect(res.body.success).toEqual(true); - - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - }); - it('should login successfully through single use code', async () => { const jurisdiction = await prisma.jurisdictions.create({ data: { diff --git a/api/test/integration/user.e2e-spec.ts b/api/test/integration/user.e2e-spec.ts index 874e024c28..096d2c30be 100644 --- a/api/test/integration/user.e2e-spec.ts +++ b/api/test/integration/user.e2e-spec.ts @@ -19,11 +19,13 @@ import { applicationFactory } from '../../prisma/seed-helpers/application-factor import { UserInvite } from '../../src/dtos/users/user-invite.dto'; import { EmailService } from '../../src/services/email.service'; import { Login } from '../../src/dtos/auth/login.dto'; +import { RequestMfaCode } from '../../src/dtos/mfa/request-mfa-code.dto'; describe('User Controller Tests', () => { let app: INestApplication; let prisma: PrismaService; let userService: UserService; + let emailService: EmailService; let cookies = ''; const invitePartnerUserMock = jest.fn(); @@ -53,6 +55,8 @@ describe('User Controller Tests', () => { app.use(cookieParser()); prisma = moduleFixture.get(PrismaService); userService = moduleFixture.get(UserService); + emailService = moduleFixture.get(EmailService); + await app.init(); const storedUser = await prisma.userAccounts.create({ @@ -604,4 +608,111 @@ describe('User Controller Tests', () => { ]); expect(res.body.email).toEqual('partneruser@email.com'); }); + + it('should request single use code successfully', async () => { + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: true, + confirmedAt: new Date(), + phoneNumber: '111-111-1111', + phoneNumberVerified: true, + }), + }); + + const jurisdiction = await prisma.jurisdictions.create({ + data: { + name: 'single_use_code_1', + allowSingleUseCodeLogin: true, + rentalAssistanceDefault: 'test', + }, + }); + emailService.sendSingleUseCode = jest.fn(); + + const res = await request(app.getHttpServer()) + .post('/user/request-single-use-code') + .send({ + email: storedUser.email, + } as RequestMfaCode) + .set({ jurisdictionname: jurisdiction.name }) + .expect(201); + + expect(res.body).toEqual({ success: true }); + + expect(emailService.sendSingleUseCode).toHaveBeenCalled(); + + const user = await prisma.userAccounts.findUnique({ + where: { + id: storedUser.id, + }, + }); + + expect(user.singleUseCode).not.toBeNull(); + expect(user.singleUseCodeUpdatedAt).not.toBeNull(); + }); + + it('should request single use code, but jurisdiction does not allow', async () => { + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: true, + confirmedAt: new Date(), + phoneNumber: '111-111-1111', + phoneNumberVerified: true, + }), + }); + + const jurisdiction = await prisma.jurisdictions.create({ + data: { + name: 'single_use_code_2', + allowSingleUseCodeLogin: false, + rentalAssistanceDefault: 'test', + }, + }); + emailService.sendSingleUseCode = jest.fn(); + + const res = await request(app.getHttpServer()) + .post('/user/request-single-use-code') + .send({ + email: storedUser.email, + } as RequestMfaCode) + .set({ jurisdictionname: jurisdiction.name }) + .expect(400); + + expect(res.body.message).toEqual( + 'Single use code login is not setup for single_use_code_2', + ); + + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + + const user = await prisma.userAccounts.findUnique({ + where: { + id: storedUser.id, + }, + }); + + expect(user.singleUseCode).toBeNull(); + }); + + it('should request single use code, but user does not exist', async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: { + name: 'single_use_code_3', + allowSingleUseCodeLogin: true, + rentalAssistanceDefault: 'test', + }, + }); + emailService.sendSingleUseCode = jest.fn(); + + const res = await request(app.getHttpServer()) + .post('/user/request-single-use-code') + .send({ + email: 'thisEmailDoesNotExist@exygy.com', + } as RequestMfaCode) + .set({ jurisdictionname: jurisdiction.name }) + .expect(201); + expect(res.body.success).toEqual(true); + + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); }); diff --git a/api/test/unit/services/auth.service.spec.ts b/api/test/unit/services/auth.service.spec.ts index ee8a1f702e..8e920525e9 100644 --- a/api/test/unit/services/auth.service.spec.ts +++ b/api/test/unit/services/auth.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { randomUUID } from 'crypto'; import { sign } from 'jsonwebtoken'; -import { Response, Request } from 'express'; +import { Response } from 'express'; import { ConfigService } from '@nestjs/config'; import { MailService } from '@sendgrid/mail'; import { @@ -29,7 +29,7 @@ import { JurisdictionService } from '../../../src/services/jurisdiction.service' import { GoogleTranslateService } from '../../../src/services/google-translate.service'; import { PermissionService } from '../../../src/services/permission.service'; import { Jurisdiction } from '../../../src/dtos/jurisdictions/jurisdiction.dto'; -import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; +import { generateSingleUseCode } from '../../../src/utilities/generate-single-use-code'; describe('Testing auth service', () => { let authService: AuthService; @@ -609,12 +609,6 @@ describe('Testing auth service', () => { expect(prisma.userAccounts.update).not.toHaveBeenCalled(); }); - it('should generate mfa code', () => { - expect(authService.generateSingleUseCode().length).toEqual( - Number(process.env.MFA_CODE_LENGTH), - ); - }); - it('should update password when correct token passed in', async () => { const id = randomUUID(); const token = sign( @@ -841,219 +835,4 @@ describe('Testing auth service', () => { ACCESS_TOKEN_AVAILABLE_OPTIONS, ); }); - - it('should request single use code but user does not exist', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue(null); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - const res = await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, - ); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.userAccounts.update).not.toHaveBeenCalled(); - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - expect(res).toEqual({ - success: true, - }); - }); - - it('should request single use code but jurisdiction does not exist', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - await expect( - async () => - await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, - ), - ).rejects.toThrowError('Jurisidiction juris 1 does not exists'); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ - select: { - id: true, - allowSingleUseCodeLogin: true, - }, - where: { - name: 'juris 1', - }, - orderBy: { - allowSingleUseCodeLogin: OrderByEnum.DESC, - }, - }); - expect(prisma.userAccounts.update).not.toHaveBeenCalled(); - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - }); - - it('should request single use code but jurisdiction disallows single use code login', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ - id: randomUUID(), - allowSingleUseCodeLogin: false, - }); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - await expect( - async () => - await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, - ), - ).rejects.toThrowError('Single use code login is not setup for juris 1'); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ - select: { - id: true, - allowSingleUseCodeLogin: true, - }, - where: { - name: 'juris 1', - }, - orderBy: { - allowSingleUseCodeLogin: OrderByEnum.DESC, - }, - }); - expect(prisma.userAccounts.update).not.toHaveBeenCalled(); - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - }); - - it('should request single use code but jurisdictionname was not sent', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - await expect( - async () => - await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - {} as unknown as Request, - ), - ).rejects.toThrowError( - 'jurisdictionname is missing from the request headers', - ); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.jurisdictions.findFirst).not.toHaveBeenCalled(); - expect(prisma.userAccounts.update).not.toHaveBeenCalled(); - expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); - }); - - it('should successfully request single use code', async () => { - const id = randomUUID(); - emailService.sendSingleUseCode = jest.fn(); - prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ - id, - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ - id, - allowSingleUseCodeLogin: true, - }); - prisma.userAccounts.update = jest.fn().mockResolvedValue({ - id, - }); - - const res = await authService.requestSingleUseCode( - { - email: 'example@exygy.com', - }, - { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, - ); - - expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ - where: { - email: 'example@exygy.com', - }, - include: { - jurisdictions: true, - }, - }); - expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ - select: { - id: true, - allowSingleUseCodeLogin: true, - }, - where: { - name: 'juris 1', - }, - orderBy: { - allowSingleUseCodeLogin: OrderByEnum.DESC, - }, - }); - expect(prisma.userAccounts.update).toHaveBeenCalledWith({ - data: { - singleUseCode: expect.anything(), - singleUseCodeUpdatedAt: expect.anything(), - }, - where: { - id, - }, - }); - expect(emailService.sendSingleUseCode).toHaveBeenCalled(); - expect(res.success).toEqual(true); - }); }); diff --git a/api/test/unit/services/user.service.spec.ts b/api/test/unit/services/user.service.spec.ts index fb23419a0e..6cfd6a259e 100644 --- a/api/test/unit/services/user.service.spec.ts +++ b/api/test/unit/services/user.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; import { PrismaService } from '../../../src/services/prisma.service'; import { UserService } from '../../../src/services/user.service'; import { randomUUID } from 'crypto'; @@ -15,6 +16,7 @@ import { SendGridService } from '../../../src/services/sendgrid.service'; import { User } from '../../../src/dtos/users/user.dto'; import { PermissionService } from '../../../src/services/permission.service'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; +import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; describe('Testing user service', () => { let service: UserService; @@ -1400,10 +1402,14 @@ describe('Testing user service', () => { }, true, undefined, + { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, + headers: { jurisdictionname: 'juris 1' }, + user: { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + } as unknown as Request, ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1472,9 +1478,12 @@ describe('Testing user service', () => { true, undefined, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, + headers: { jurisdictionname: 'juris 1' }, + user: { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + } as unknown as Request, ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1552,9 +1561,12 @@ describe('Testing user service', () => { true, undefined, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, + headers: { jurisdictionname: 'juris 1' }, + user: { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + } as unknown as Request, ), ).rejects.toThrowError('emailInUse'); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ @@ -1613,9 +1625,12 @@ describe('Testing user service', () => { false, undefined, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, + headers: { jurisdictionname: 'juris 1' }, + user: { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + } as unknown as Request, ); expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ include: { @@ -1629,13 +1644,19 @@ describe('Testing user service', () => { }); expect(prisma.userAccounts.create).toHaveBeenCalledWith({ data: { + dob: undefined, passwordHash: expect.anything(), + phoneNumber: undefined, + userRoles: undefined, email: 'publicUser@email.com', firstName: 'public User firstName', lastName: 'public User lastName', + language: undefined, + listings: undefined, + middleName: undefined, mfaEnabled: false, jurisdictions: { - connect: [{ id: jurisId }], + connect: { name: 'juris 1' }, }, }, }); @@ -1785,4 +1806,219 @@ describe('Testing user service', () => { expect(res).toEqual(false); }); }); + + it('should request single use code but user does not exist', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue(null); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + const res = await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + expect(res).toEqual({ + success: true, + }); + }); + + it('should request single use code but jurisdiction does not exist', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ), + ).rejects.toThrowError('Jurisidiction juris 1 does not exists'); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + + it('should request single use code but jurisdiction disallows single use code login', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id: randomUUID(), + allowSingleUseCodeLogin: false, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ), + ).rejects.toThrowError('Single use code login is not setup for juris 1'); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + + it('should request single use code but jurisdictionname was not sent', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + {} as unknown as Request, + ), + ).rejects.toThrowError( + 'jurisdictionname is missing from the request headers', + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).not.toHaveBeenCalled(); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + + it('should successfully request single use code', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id, + allowSingleUseCodeLogin: true, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + const res = await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + allowSingleUseCodeLogin: true, + }, + where: { + name: 'juris 1', + }, + orderBy: { + allowSingleUseCodeLogin: OrderByEnum.DESC, + }, + }); + expect(prisma.userAccounts.update).toHaveBeenCalledWith({ + data: { + singleUseCode: expect.anything(), + singleUseCodeUpdatedAt: expect.anything(), + }, + where: { + id, + }, + }); + expect(emailService.sendSingleUseCode).toHaveBeenCalled(); + expect(res.success).toEqual(true); + }); }); diff --git a/api/test/unit/utilities/generate-single-use-code.spec.ts b/api/test/unit/utilities/generate-single-use-code.spec.ts new file mode 100644 index 0000000000..a2cc479526 --- /dev/null +++ b/api/test/unit/utilities/generate-single-use-code.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import { generateSingleUseCode } from '../../../src/utilities/generate-single-use-code'; +import { AppModule } from '../../../src/modules/app.module'; + +describe('generateSingleUseCode', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + it('should generate mfa code of the specified length', () => { + expect(generateSingleUseCode().length).toEqual( + Number(process.env.MFA_CODE_LENGTH), + ); + }); +}); diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index d6e141d290..f3399c1078 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -365,7 +365,7 @@ export const AuthProvider: FunctionComponent = ({ child requestSingleUseCode: async (email) => { dispatch(startLoading()) try { - return await authService?.requestSingleUseCode({ + return await userService?.requestSingleUseCode({ body: { email }, }) } finally { diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index c4b57ec052..fbe99470ad 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -20,6 +20,7 @@ "account.signUpSaveTime.applyFaster": "Apply faster with saved application details", "account.signUpSaveTime.checkStatus": "Check on the status of an application at any time", "account.signUpSaveTime.resetPassword": "Simply reset your password if you forget it", + "account.signUpSaveTime.useACode": "Use a code to sign in without a password", "account.signUpSaveTime.subTitle": "Having an account will save you time by using saved application details, and allow you to check the status of an application at anytime.", "account.signUpSaveTime.title": "Sign up quickly and check application status at anytime", "account.settings.alerts.currentPassword": "Invalid current password. Please try again.", @@ -211,15 +212,19 @@ "application.household.preferredUnit.title": "What unit sizes are you interested in?", "application.household.primaryApplicant": "Primary Applicant", "application.name.dobHelper": "For example: 01 19 2000", + "application.name.dobHelper2": "This is collected to verify that you are at least 18 years old.", "application.name.emailPrivacy": "We will only use your email address to contact you about your application.", "application.name.firstName": "First Name", + "application.name.firstOrGivenName": "First or Given Name", "application.name.lastName": "Last Name", + "application.name.lastOrFamilyName": "Last or Family Name", "application.name.middleName": "Middle Name", "application.name.middleNameOptional": "Middle Name (optional)", "application.name.noEmailAddress": "I don't have an email address", "application.name.title": "What's your name?", "application.name.yourDateOfBirth": "Your Date of Birth", "application.name.yourEmailAddress": "Your Email Address", + "application.name.yourEmailAddressPwdlessHelper": "Enter your email address and we'll send you a code for a password-free sign in.", "application.name.yourName": "Your Name", "application.preferences.HOPWA.doNotConsider.label": "I don't want to be considered", "application.preferences.HOPWA.hopwa.description": "%{county} copy goes hereā€¦", diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 18d0892082..35479ac75f 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1723,6 +1723,28 @@ export class UserService { axios(configs, resolve, reject) }) } + /** + * Request single use code + */ + requestSingleUseCode( + params: { + /** requestBody */ + body?: RequestSingleUseCode + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/request-single-use-code" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Resend public confirmation */ @@ -1872,28 +1894,6 @@ export class AuthService { axios(configs, resolve, reject) }) } - /** - * Request single use code - */ - requestSingleUseCode( - params: { - /** requestBody */ - body?: RequestSingleUseCode - } = {} as any, - options: IRequestOptions = {} - ): Promise { - return new Promise((resolve, reject) => { - let url = basePath + "/auth/request-single-use-code" - - const configs: IRequestConfig = getConfigs("post", "application/json", url, options) - - let data = params.body - - configs.data = data - - axios(configs, resolve, reject) - }) - } /** * Requests a new token given a refresh token */ @@ -2666,147 +2666,6 @@ export interface UnitsSummary { totalAvailable?: number } -export interface UserRole { - /** */ - isAdmin?: boolean - - /** */ - isJurisdictionalAdmin?: boolean - - /** */ - isPartner?: boolean -} - -export interface Jurisdiction { - /** */ - id: string - - /** */ - createdAt: Date - - /** */ - updatedAt: Date - - /** */ - name: string - - /** */ - notificationsSignUpUrl?: string - - /** */ - languages: LanguagesEnum[] - - /** */ - multiselectQuestions: IdDTO[] - - /** */ - partnerTerms?: string - - /** */ - publicUrl: string - - /** */ - emailFromAddress: string - - /** */ - rentalAssistanceDefault: string - - /** */ - enablePartnerSettings?: boolean - - /** */ - enablePartnerDemographics?: boolean - - /** */ - enableGeocodingPreferences?: boolean - - /** */ - enableAccessibilityFeatures: boolean - - /** */ - enableUtilitiesIncluded: boolean - - /** */ - allowSingleUseCodeLogin: boolean - - /** */ - listingApprovalPermissions: EnumJurisdictionListingApprovalPermissions[] -} - -export interface User { - /** */ - id: string - - /** */ - createdAt: Date - - /** */ - updatedAt: Date - - /** */ - passwordUpdatedAt: Date - - /** */ - passwordValidForDays: number - - /** */ - confirmedAt?: Date - - /** */ - email: string - - /** */ - firstName: string - - /** */ - middleName?: string - - /** */ - lastName: string - - /** */ - dob?: Date - - /** */ - phoneNumber?: string - - /** */ - listings: IdDTO[] - - /** */ - userRoles?: UserRole - - /** */ - language?: LanguagesEnum - - /** */ - jurisdictions: Jurisdiction[] - - /** */ - mfaEnabled?: boolean - - /** */ - lastLoginAt?: Date - - /** */ - failedLoginAttemptsCount?: number - - /** */ - phoneNumberVerified?: boolean - - /** */ - agreedToTermsOfService: boolean - - /** */ - hitConfirmationURL?: Date - - /** */ - activeAccessToken?: string - - /** */ - activeRefreshToken?: string -} - export interface Listing { /** */ id: string @@ -4445,6 +4304,62 @@ export interface JurisdictionUpdate { listingApprovalPermissions: EnumJurisdictionUpdateListingApprovalPermissions[] } +export interface Jurisdiction { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string + + /** */ + notificationsSignUpUrl?: string + + /** */ + languages: LanguagesEnum[] + + /** */ + multiselectQuestions: IdDTO[] + + /** */ + partnerTerms?: string + + /** */ + publicUrl: string + + /** */ + emailFromAddress: string + + /** */ + rentalAssistanceDefault: string + + /** */ + enablePartnerSettings?: boolean + + /** */ + enablePartnerDemographics?: boolean + + /** */ + enableGeocodingPreferences?: boolean + + /** */ + enableAccessibilityFeatures: boolean + + /** */ + enableUtilitiesIncluded: boolean + + /** */ + allowSingleUseCodeLogin: boolean + + /** */ + listingApprovalPermissions: EnumJurisdictionListingApprovalPermissions[] +} + export interface MultiselectQuestionCreate { /** */ text: string @@ -4913,6 +4828,91 @@ export interface EmailAndAppUrl { appUrl?: string } +export interface UserRole { + /** */ + isAdmin?: boolean + + /** */ + isJurisdictionalAdmin?: boolean + + /** */ + isPartner?: boolean +} + +export interface User { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + passwordUpdatedAt: Date + + /** */ + passwordValidForDays: number + + /** */ + confirmedAt?: Date + + /** */ + email: string + + /** */ + firstName: string + + /** */ + middleName?: string + + /** */ + lastName: string + + /** */ + dob?: Date + + /** */ + phoneNumber?: string + + /** */ + listings: IdDTO[] + + /** */ + userRoles?: UserRole + + /** */ + language?: LanguagesEnum + + /** */ + jurisdictions: Jurisdiction[] + + /** */ + mfaEnabled?: boolean + + /** */ + lastLoginAt?: Date + + /** */ + failedLoginAttemptsCount?: number + + /** */ + phoneNumberVerified?: boolean + + /** */ + agreedToTermsOfService: boolean + + /** */ + hitConfirmationURL?: Date + + /** */ + activeAccessToken?: string + + /** */ + activeRefreshToken?: string +} + export interface UserFilterParams { /** */ isPortalUser?: boolean @@ -5061,6 +5061,11 @@ export interface UserInvite { jurisdictions: IdDTO[] } +export interface RequestSingleUseCode { + /** */ + email: string +} + export interface ConfirmationRequest { /** */ token: string @@ -5113,11 +5118,6 @@ export interface RequestMfaCodeResponse { phoneNumberVerified?: boolean } -export interface RequestSingleUseCode { - /** */ - email: string -} - export interface UpdatePassword { /** */ password: string @@ -5247,12 +5247,6 @@ export enum UnitRentTypeEnum { "fixed" = "fixed", "percentageOfIncome" = "percentageOfIncome", } -export enum EnumJurisdictionListingApprovalPermissions { - "user" = "user", - "partner" = "partner", - "admin" = "admin", - "jurisdictionAdmin" = "jurisdictionAdmin", -} export enum AfsView { "pending" = "pending", @@ -5312,6 +5306,12 @@ export enum EnumJurisdictionUpdateListingApprovalPermissions { "admin" = "admin", "jurisdictionAdmin" = "jurisdictionAdmin", } +export enum EnumJurisdictionListingApprovalPermissions { + "user" = "user", + "partner" = "partner", + "admin" = "admin", + "jurisdictionAdmin" = "jurisdictionAdmin", +} export enum EnumMultiselectQuestionFilterParamsComparison { "=" = "=", "<>" = "<>", diff --git a/sites/public/src/components/account/SignUpBenefits.tsx b/sites/public/src/components/account/SignUpBenefits.tsx index 2a4b6c8864..d706621328 100644 --- a/sites/public/src/components/account/SignUpBenefits.tsx +++ b/sites/public/src/components/account/SignUpBenefits.tsx @@ -11,8 +11,14 @@ const SignUpBenefits = (props: SignUpBenefitsProps) => { const iconListItems = [ { icon: faStopwatch, text: t("account.signUpSaveTime.applyFaster") }, { icon: faEye, text: t("account.signUpSaveTime.checkStatus") }, - { icon: faLock, text: t("account.signUpSaveTime.resetPassword") }, + { + icon: faLock, + text: process.env.showPwdless + ? t("account.signUpSaveTime.useACode") + : t("account.signUpSaveTime.resetPassword"), + }, ] + const classNames = [styles["sign-up-benefits-container"]] if (props.className) classNames.push(props.className) return ( diff --git a/sites/public/src/pages/applications/contact/name.tsx b/sites/public/src/pages/applications/contact/name.tsx index f05c3b181a..6cfe6e3cc7 100644 --- a/sites/public/src/pages/applications/contact/name.tsx +++ b/sites/public/src/pages/applications/contact/name.tsx @@ -106,7 +106,7 @@ const ApplicationName = () => { { { listingIdRedirect ) - setOpenModal(true) + if (process.env.showPwdless) { + const redirectUrl = router.query?.redirectUrl as string + const listingId = router.query?.listingId as string + let queryParams: { [key: string]: string } = { email: data.email, flowType: "create" } + if (redirectUrl) queryParams = { ...queryParams, redirectUrl } + if (listingId) queryParams = { ...queryParams, listingId } + + await router.push({ + pathname: "/verify", + query: queryParams, + }) + } else { + setOpenModal(true) + } } catch (err) { const { status, data } = err.response || {} if (status === 400) { @@ -108,7 +121,7 @@ export default () => { { { /> @@ -170,7 +179,12 @@ export default () => { errorMessage={t("errors.dateOfBirthErrorAge")} label={t("application.name.yourDateOfBirth")} /> -

{t("application.name.dobHelper")}

+

+ {t("application.name.dobHelper2")} +

+

+ {t("application.name.dobHelper")} +

{ register={register} controlClassName={styles["create-account-input"]} labelClassName={"text__caps-spaced"} + note={ + process.env.showPwdless + ? t("application.name.yourEmailAddressPwdlessHelper") + : null + } /> { const [isModalOpen, setIsModalOpen] = useState(false) const [isResendLoading, setIsResendLoading] = useState(false) const [isLoginLoading, setIsLoginLoading] = useState(false) - const alertMessage = + const [alertMessage, setAlertMessage] = useState( flowType === "create" ? t("account.pwdless.createMessage", { email }) : t("account.pwdless.loginMessage", { email }) + ) useEffect(() => { pushGtmEvent({ @@ -58,7 +59,11 @@ const Verify = () => { setIsLoginLoading(true) const user = await loginViaSingleUseCode(email, code) setIsLoginLoading(false) - setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + if (flowType === "login") { + setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + } else { + setSiteAlertMessage(t("authentication.createAccount.accountConfirmed"), "success") + } await redirectToPage() } catch (error) { setIsLoginLoading(false) @@ -149,9 +154,11 @@ const Verify = () => { setIsResendLoading(true) await requestSingleUseCode(email) setIsResendLoading(false) + setAlertMessage(t("account.pwdless.codeNewAlert", { email })) setIsModalOpen(false) } catch (error) { setIsResendLoading(false) + setIsModalOpen(false) const { status } = error.response || {} determineNetworkError(status, error) } diff --git a/sites/public/styles/create-account.module.scss b/sites/public/styles/create-account.module.scss index ebfa1a3103..549b85a6a2 100644 --- a/sites/public/styles/create-account.module.scss +++ b/sites/public/styles/create-account.module.scss @@ -24,3 +24,11 @@ .create-account-label { margin-bottom: var(--bloom-s1); } + +.create-account-dob-age-helper { + margin-top: var(--seeds-s4); +} + +.create-account-dob-example { + margin-top: var(--seeds-s2); +}