diff --git a/api/.env.template b/api/.env.template index 637b94ffb5..537c1333fb 100644 --- a/api/.env.template +++ b/api/.env.template @@ -44,3 +44,7 @@ CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"] CORS_REGEX=["test1", "test2"] # controls the repetition of the temp file clearing cron job TEMP_FILE_CLEAR_CRON_STRING=0 * * * +# how long we maintain our request time outs (60 * 60 * 1000 ms) +THROTTLE_TTL=3600000 +# how many requests before we throttle +THROTTLE_LIMIT=100 diff --git a/api/package.json b/api/package.json index 54b00abc09..dbc45c7cb9 100644 --- a/api/package.json +++ b/api/package.json @@ -43,11 +43,12 @@ "@nestjs/platform-express": "^10.3.2", "@nestjs/schedule": "^4.0.1", "@nestjs/swagger": "~7.1.12", + "@nestjs/throttler": "^5.1.2", "@prisma/client": "^5.0.0", "@sendgrid/mail": "7.7.0", + "@turf/boolean-point-in-polygon": "6.5.0", "@turf/buffer": "6.5.0", "@turf/helpers": "6.5.0", - "@turf/boolean-point-in-polygon": "6.5.0", "@turf/points-within-polygon": "6.5.0", "@types/archiver": "^6.0.2", "archiver": "^6.0.1", diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 357118d71a..1ef6491b50 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -45,7 +45,10 @@ export const devSeeding = async ( jurisdictionName?: string, ) => { const jurisdiction = await prismaClient.jurisdictions.create({ - data: jurisdictionFactory(jurisdictionName), + data: { + ...jurisdictionFactory(jurisdictionName), + allowSingleUseCodeLogin: false, + }, }); await prismaClient.userAccounts.create({ data: await userFactory({ diff --git a/api/prisma/seed-helpers/map-layer-factory.ts b/api/prisma/seed-helpers/map-layer-factory.ts index e571c4625a..afa4ba20db 100644 --- a/api/prisma/seed-helpers/map-layer-factory.ts +++ b/api/prisma/seed-helpers/map-layer-factory.ts @@ -22,20 +22,18 @@ export const simplifiedDCMap = { geometry: { coordinates: [ [ - [ - [-77.0392589333301, 38.79186072967565], - [-76.90981025809415, 38.89293952026222], - [-77.04122027689426, 38.996161202682146], - [-77.12000091005532, 38.93465307055658], - [-77.10561772391833, 38.91990351952725], - [-77.09123453778136, 38.90565966392609], - [-77.06802530560486, 38.9015894658674], - [-77.06181438431805, 38.889377471720564], - [-77.03697069917165, 38.870801038935525], - [-77.03043288729134, 38.850437727576235], - [-77.03435557441966, 38.80816525459605], - [-77.0392589333301, 38.79186072967565], - ], + [-77.0392589333301, 38.79186072967565], + [-76.90981025809415, 38.89293952026222], + [-77.04122027689426, 38.996161202682146], + [-77.12000091005532, 38.93465307055658], + [-77.10561772391833, 38.91990351952725], + [-77.09123453778136, 38.90565966392609], + [-77.06802530560486, 38.9015894658674], + [-77.06181438431805, 38.889377471720564], + [-77.03697069917165, 38.870801038935525], + [-77.03043288729134, 38.850437727576235], + [-77.03435557441966, 38.80816525459605], + [-77.0392589333301, 38.79186072967565], ], ], type: 'Polygon', diff --git a/api/src/controllers/ami-chart.controller.ts b/api/src/controllers/ami-chart.controller.ts index e5047d4565..199bd370aa 100644 --- a/api/src/controllers/ami-chart.controller.ts +++ b/api/src/controllers/ami-chart.controller.ts @@ -28,12 +28,13 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/amiCharts') @ApiTags('amiCharts') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @PermissionTypeDecorator('amiChart') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) @ApiExtraModels(AmiChartQueryParams) export class AmiChartController { constructor(private readonly AmiChartService: AmiChartService) {} diff --git a/api/src/controllers/app.controller.ts b/api/src/controllers/app.controller.ts index 7f5c557e80..03f831174f 100644 --- a/api/src/controllers/app.controller.ts +++ b/api/src/controllers/app.controller.ts @@ -18,8 +18,10 @@ import { PermissionAction } from '../decorators/permission-action.decorator'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; import { AppService } from '../services/app.service'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller() +@UseGuards(ThrottleGuard) @ApiExtraModels(SuccessDTO) @ApiTags('root') export class AppController { diff --git a/api/src/controllers/application-flagged-set.controller.ts b/api/src/controllers/application-flagged-set.controller.ts index 761a0e98a6..b6b4703e82 100644 --- a/api/src/controllers/application-flagged-set.controller.ts +++ b/api/src/controllers/application-flagged-set.controller.ts @@ -32,11 +32,12 @@ import { mapTo } from '../utilities/mapTo'; import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/applicationFlaggedSets') @ApiExtraModels(SuccessDTO) @ApiTags('applicationFlaggedSets') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) @PermissionTypeDecorator('applicationFlaggedSet') @UsePipes( new ValidationPipe({ diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index 4f612b49c0..2e277d45d9 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -49,6 +49,7 @@ import { ApplicationCsvExporterService } from '../services/application-csv-expor import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; import { MostRecentApplicationQueryParams } from '../dtos/applications/most-recent-application-query-params.dto'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('applications') @ApiTags('applications') @@ -59,7 +60,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; }), ) @ApiExtraModels(IdDTO, AddressInput, BooleanInput, TextInput) -@UseGuards(OptionalAuthGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard) @PermissionTypeDecorator('application') @UseInterceptors(ActivityLogInterceptor) export class ApplicationController { diff --git a/api/src/controllers/asset.controller.ts b/api/src/controllers/asset.controller.ts index dbb66c04f7..ae21f50683 100644 --- a/api/src/controllers/asset.controller.ts +++ b/api/src/controllers/asset.controller.ts @@ -19,6 +19,7 @@ import { CreatePresignedUploadMetadataResponse } from '../dtos/assets/create-pre import { CreatePresignedUploadMetadata } from '../dtos/assets/create-presigned-upload-meta.dto'; import { AssetService } from '../services/asset.service'; import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('assets') @ApiTags('assets') @@ -28,7 +29,7 @@ import { defaultValidationPipeOptions } from '../utilities/default-validation-pi CreatePresignedUploadMetadataResponse, ) @PermissionTypeDecorator('asset') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class AssetController { constructor(private readonly assetService: AssetService) {} diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index 3861e1c807..497d01e4c8 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -32,6 +32,7 @@ import { User } from '../dtos/users/user.dto'; import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto'; import { SingleUseCodeAuthGuard } from '../guards/single-use-code.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('auth') @ApiTags('auth') @@ -43,7 +44,7 @@ export class AuthController { @ApiOperation({ summary: 'Login', operationId: 'login' }) @ApiOkResponse({ type: SuccessDTO }) @ApiBody({ type: Login }) - @UseGuards(MfaAuthGuard) + @UseGuards(ThrottleGuard, MfaAuthGuard) async login( @Request() req: ExpressRequest, @Response({ passthrough: true }) res: ExpressResponse, @@ -63,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') @@ -89,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/jurisdiction.controller.ts b/api/src/controllers/jurisdiction.controller.ts index e7263240c3..2e0433cb18 100644 --- a/api/src/controllers/jurisdiction.controller.ts +++ b/api/src/controllers/jurisdiction.controller.ts @@ -27,13 +27,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('jurisdictions') @ApiTags('jurisdictions') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(JurisdictionCreate, JurisdictionUpdate, IdDTO) @PermissionTypeDecorator('jurisdiction') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) export class JurisdictionController { constructor(private readonly jurisdictionService: JurisdictionService) {} diff --git a/api/src/controllers/listing.controller.ts b/api/src/controllers/listing.controller.ts index 8fae0b99fc..d661a434a5 100644 --- a/api/src/controllers/listing.controller.ts +++ b/api/src/controllers/listing.controller.ts @@ -53,6 +53,7 @@ import { ListingCsvExporterService } from '../services/listing-csv-export.servic import { ListingCsvQueryParams } from '../dtos/listings/listing-csv-query-params.dto'; import { PermissionGuard } from '../guards/permission.guard'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('listings') @ApiTags('listings') @@ -63,7 +64,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; PaginationAllowsAllQueryParams, IdDTO, ) -@UseGuards(OptionalAuthGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard) @PermissionTypeDecorator('listing') @ActivityLogMetadata([{ targetPropertyName: 'status', propertyPath: 'status' }]) @UseInterceptors(ActivityLogInterceptor) diff --git a/api/src/controllers/map-layer.controller.ts b/api/src/controllers/map-layer.controller.ts index 205e53dcb9..36c498debf 100644 --- a/api/src/controllers/map-layer.controller.ts +++ b/api/src/controllers/map-layer.controller.ts @@ -14,10 +14,11 @@ import { defaultValidationPipeOptions } from '../utilities/default-validation-pi import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('/mapLayers') @ApiTags('mapLayers') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) @PermissionTypeDecorator('mapLayers') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) export class MapLayersController { diff --git a/api/src/controllers/multiselect-question.controller.ts b/api/src/controllers/multiselect-question.controller.ts index 4a578f30bf..0b642e612b 100644 --- a/api/src/controllers/multiselect-question.controller.ts +++ b/api/src/controllers/multiselect-question.controller.ts @@ -33,6 +33,7 @@ import { OptionalAuthGuard } from '../guards/optional.guard'; import { PermissionGuard } from '../guards/permission.guard'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('multiselectQuestions') @ApiTags('multiselectQuestions') @@ -46,7 +47,7 @@ import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor IdDTO, ) @PermissionTypeDecorator('multiselectQuestion') -@UseGuards(OptionalAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, OptionalAuthGuard, PermissionGuard) export class MultiselectQuestionController { constructor( private readonly multiselectQuestionService: MultiselectQuestionService, diff --git a/api/src/controllers/reserved-community-type.controller.ts b/api/src/controllers/reserved-community-type.controller.ts index a8d936e2e4..1925b3bff1 100644 --- a/api/src/controllers/reserved-community-type.controller.ts +++ b/api/src/controllers/reserved-community-type.controller.ts @@ -28,13 +28,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('reservedCommunityTypes') @ApiTags('reservedCommunityTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(ReservedCommunityTypeQueryParams) @PermissionTypeDecorator('reservedCommunityType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class ReservedCommunityTypeController { constructor( private readonly ReservedCommunityTypeService: ReservedCommunityTypeService, diff --git a/api/src/controllers/unit-accessibility-priority-type.controller.ts b/api/src/controllers/unit-accessibility-priority-type.controller.ts index a185a240ab..14d82739b8 100644 --- a/api/src/controllers/unit-accessibility-priority-type.controller.ts +++ b/api/src/controllers/unit-accessibility-priority-type.controller.ts @@ -26,13 +26,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitAccessibilityPriorityTypes') @ApiTags('unitAccessibilityPriorityTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(IdDTO) @PermissionTypeDecorator('unitAccessibilityPriorityType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitAccessibilityPriorityTypeController { constructor( private readonly unitAccessibilityPriorityTypeService: UnitAccessibilityPriorityTypeService, diff --git a/api/src/controllers/unit-rent-type.controller.ts b/api/src/controllers/unit-rent-type.controller.ts index b7035ec142..ee8901afa1 100644 --- a/api/src/controllers/unit-rent-type.controller.ts +++ b/api/src/controllers/unit-rent-type.controller.ts @@ -26,13 +26,14 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitRentTypes') @ApiTags('unitRentTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @ApiExtraModels(UnitRentTypeCreate, UnitRentTypeUpdate, IdDTO) @PermissionTypeDecorator('unitRentType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitRentTypeController { constructor(private readonly unitRentTypeService: UnitRentTypeService) {} diff --git a/api/src/controllers/unit-type.controller.ts b/api/src/controllers/unit-type.controller.ts index ac23f9c3fa..04e9cdcc72 100644 --- a/api/src/controllers/unit-type.controller.ts +++ b/api/src/controllers/unit-type.controller.ts @@ -21,12 +21,13 @@ import { SuccessDTO } from '../dtos/shared/success.dto'; import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { JwtAuthGuard } from '../guards/jwt.guard'; import { PermissionGuard } from '../guards/permission.guard'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Controller('unitTypes') @ApiTags('unitTypes') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @PermissionTypeDecorator('unitType') -@UseGuards(JwtAuthGuard, PermissionGuard) +@UseGuards(ThrottleGuard, JwtAuthGuard, PermissionGuard) export class UnitTypeController { constructor(private readonly unitTypeService: UnitTypeService) {} diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index eb0de49ef4..4b371086da 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -49,8 +49,11 @@ 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') +@UseGuards(ThrottleGuard) @ApiTags('user') @PermissionTypeDecorator('user') @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) @@ -168,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, ); } @@ -187,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/guards/throttler.guard.ts b/api/src/guards/throttler.guard.ts new file mode 100644 index 0000000000..d31dee1631 --- /dev/null +++ b/api/src/guards/throttler.guard.ts @@ -0,0 +1,22 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { ThrottlerLimitDetail } from '@nestjs/throttler/dist/throttler.guard.interface'; + +@Injectable() +export class ThrottleGuard extends ThrottlerGuard { + protected async getTracker(req: Record): Promise { + if (req?.headers && req.headers['x-forwarded-for']) { + // if we are passing through the proxy use forwarded for + return req.headers['x-forwarded-for'].split(',')[0]; + } + return req.ips.length ? req.ips[0] : req.ip; + } + + protected async throwThrottlingException( + context: ExecutionContext, + throttlerLimitDetail: ThrottlerLimitDetail, + ): Promise { + console.error(`IP Address: ${throttlerLimitDetail.tracker} was throttled`); + await super.throwThrottlingException(context, throttlerLimitDetail); + } +} diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts index d8c57952e3..787f233909 100644 --- a/api/src/modules/app.module.ts +++ b/api/src/modules/app.module.ts @@ -17,6 +17,9 @@ import { UserModule } from './user.module'; import { AuthModule } from './auth.module'; import { ApplicationFlaggedSetModule } from './application-flagged-set.module'; import { MapLayerModule } from './map-layer.module'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerModule } from '@nestjs/throttler'; +import { ThrottleGuard } from '../guards/throttler.guard'; @Module({ imports: [ @@ -35,9 +38,23 @@ import { MapLayerModule } from './map-layer.module'; AuthModule, ApplicationFlaggedSetModule, MapLayerModule, + ThrottlerModule.forRoot([ + { + ttl: Number(process.env.THROTTLE_TTL), + limit: Number(process.env.THROTTLE_LIMIT), + }, + ]), ], controllers: [AppController], - providers: [AppService, Logger, SchedulerRegistry], + providers: [ + AppService, + Logger, + SchedulerRegistry, + { + provide: APP_GUARD, + useClass: ThrottleGuard, + }, + ], exports: [ ListingModule, AmiChartModule, diff --git a/api/src/services/application-csv-export.service.ts b/api/src/services/application-csv-export.service.ts index 5ae32d2d4d..495dfaad0d 100644 --- a/api/src/services/application-csv-export.service.ts +++ b/api/src/services/application-csv-export.service.ts @@ -23,7 +23,11 @@ import { mapTo } from '../utilities/mapTo'; view.csv = { ...view.details, - applicationFlaggedSet: true, + applicationFlaggedSet: { + select: { + id: true, + }, + }, listings: false, }; @@ -81,12 +85,10 @@ export class ApplicationCsvExporterService filename: string, queryParams: QueryParams, ): Promise { - if (queryParams.includeDemographics) { - view.csv.demographics = true; - } - const applications = await this.prisma.applications.findMany({ - include: view.csv, + select: { + id: true, + }, where: { listingId: queryParams.listingId, deletedAt: null, @@ -110,7 +112,7 @@ export class ApplicationCsvExporterService queryParams.includeDemographics, ); - return new Promise((resolve, reject) => { + return new Promise(async (resolve, reject) => { // create stream const writableStream = fs.createWriteStream(`${filename}`); writableStream @@ -122,80 +124,109 @@ export class ApplicationCsvExporterService .on('close', () => { resolve(); }) - .on('open', () => { + .on('open', async () => { writableStream.write( csvHeaders .map((header) => `"${header.label.replace(/"/g, `""`)}"`) .join(',') + '\n', ); - // now loop over applications and write them to file - applications.forEach((app) => { - let row = ''; - let preferences: ApplicationMultiselectQuestion[]; - csvHeaders.forEach((header, index) => { - let multiselectQuestionValue = false; - let parsePreference = false; - let value = header.path.split('.').reduce((acc, curr) => { - // return preference/program as value for the format function to accept - if (multiselectQuestionValue) { - return acc; - } + for (let i = 0; i < applications.length / 1000 + 1; i++) { + // grab applications 1k at a time + const paginatedApplications = + await this.prisma.applications.findMany({ + include: { + ...view.csv, + demographics: queryParams.includeDemographics + ? { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + } + : false, + }, + where: { + listingId: queryParams.listingId, + deletedAt: null, + }, + skip: i * 1000, + take: 1000, + }); - if (parsePreference) { - // curr should equal the preference id we're pulling from - if (!preferences) { - preferences = - app.preferences as unknown as ApplicationMultiselectQuestion[]; + // now loop over applications and write them to file + paginatedApplications.forEach((app) => { + let row = ''; + let preferences: ApplicationMultiselectQuestion[]; + csvHeaders.forEach((header, index) => { + let multiselectQuestionValue = false; + let parsePreference = false; + let value = header.path.split('.').reduce((acc, curr) => { + // return preference/program as value for the format function to accept + if (multiselectQuestionValue) { + return acc; } - parsePreference = false; - // there aren't typically many preferences, but if there, then a object map should be created and used - const preference = preferences.find( - (preference) => preference.multiselectQuestionId === curr, - ); - multiselectQuestionValue = true; - return preference; - } - // sets parsePreference to true, for the next iteration - if (curr === 'preferences') { - parsePreference = true; - } + if (parsePreference) { + // curr should equal the preference id we're pulling from + if (!preferences) { + preferences = + app.preferences as unknown as ApplicationMultiselectQuestion[]; + } + parsePreference = false; + // there aren't typically many preferences, but if there, then a object map should be created and used + const preference = preferences.find( + (preference) => preference.multiselectQuestionId === curr, + ); + multiselectQuestionValue = true; + return preference; + } - if (acc === null || acc === undefined) { - return ''; - } + // sets parsePreference to true, for the next iteration + if (curr === 'preferences') { + parsePreference = true; + } - // handles working with arrays, e.g. householdMember.0.firstName - if (!isNaN(Number(curr))) { - const index = Number(curr); - return acc[index]; - } + if (acc === null || acc === undefined) { + return ''; + } - return acc[curr]; - }, app); - value = value === undefined ? '' : value === null ? '' : value; - if (header.format) { - value = header.format(value); - } + // handles working with arrays, e.g. householdMember.0.firstName + if (!isNaN(Number(curr))) { + const index = Number(curr); + return acc[index]; + } - row += value ? `"${value.toString().replace(/"/g, `""`)}"` : ''; - if (index < csvHeaders.length - 1) { - row += ','; - } - }); + return acc[curr]; + }, app); + value = value === undefined ? '' : value === null ? '' : value; + if (header.format) { + value = header.format(value); + } - try { - writableStream.write(row + '\n'); - } catch (e) { - console.log('writeStream write error = ', e); - writableStream.once('drain', () => { - console.log('drain buffer'); - writableStream.write(row + '\n'); + row += value ? `"${value.toString().replace(/"/g, `""`)}"` : ''; + if (index < csvHeaders.length - 1) { + row += ','; + } }); - } - }); + try { + writableStream.write(row + '\n'); + } catch (e) { + console.log('writeStream write error = ', e); + writableStream.once('drain', () => { + console.log('drain buffer'); + writableStream.write(row + '\n'); + }); + } + }); + } writableStream.end(); }); }); diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index 489160d929..952c10c485 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -33,18 +33,125 @@ export const view: Partial< > = { partnerList: { applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, + select: { + id: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + emailAddress: true, + noEmail: true, + phoneNumber: true, + phoneNumberType: true, + noPhone: true, + workInRegion: true, + applicantAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicantWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + householdMember: { + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + }, + }, + accessibility: { + select: { + id: true, + mobility: true, + vision: true, + hearing: true, + }, + }, + applicationsMailingAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicationsAlternateAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, }, }, - householdMember: true, - accessibility: true, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, alternateContact: { - include: { - address: true, + select: { + id: true, + type: true, + otherType: true, + firstName: true, + lastName: true, + agency: true, + phoneNumber: true, + emailAddress: true, + address: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, }, }, listings: { @@ -57,20 +164,92 @@ export const view: Partial< view.base = { ...view.partnerList, - demographics: true, - preferredUnitTypes: true, - listings: true, + demographics: { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + }, + preferredUnitTypes: { + select: { + id: true, + name: true, + numBedrooms: true, + }, + }, + listings: { + select: { + id: true, + name: true, + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }, householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + householdMemberAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + householdMemberWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, }, }, }; view.details = { ...view.base, - userAccounts: true, + userAccounts: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, }; /* 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/geocoding.service.ts b/api/src/services/geocoding.service.ts index 540f363349..1e285b8a6e 100644 --- a/api/src/services/geocoding.service.ts +++ b/api/src/services/geocoding.service.ts @@ -1,4 +1,4 @@ -import { FeatureCollection, point, polygons } from '@turf/helpers'; +import { FeatureCollection, Polygon, point } from '@turf/helpers'; import buffer from '@turf/buffer'; import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; import { MapLayers, Prisma } from '@prisma/client'; @@ -72,21 +72,10 @@ export class GeocodingService { Number.parseFloat(preferenceAddress.latitude.toString()), ]); - // Convert the features to the format that turfjs wants - const polygonsFromFeature = []; - featureCollectionLayers.features.forEach((feature) => { - if ( - feature.geometry.type === 'MultiPolygon' || - feature.geometry.type === 'Polygon' - ) { - feature.geometry.coordinates.forEach((coordinate) => { - polygonsFromFeature.push(coordinate); - }); - } - }); - const layer = polygons(polygonsFromFeature); - - const points = pointsWithinPolygon(preferencePoint, layer); + const points = pointsWithinPolygon( + preferencePoint, + featureCollectionLayers as FeatureCollection, + ); if (points && points.features?.length) { return true; } 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/src/views/partials/user-name.hbs b/api/src/views/partials/user-name.hbs index 5f8a7f3e24..52e44c0cf3 100644 --- a/api/src/views/partials/user-name.hbs +++ b/api/src/views/partials/user-name.hbs @@ -1,4 +1,4 @@ {{user.firstName}} {{#if user.middleName}} {{user.middleName}} -{{/if}} {{user.lastName}} +{{/if}} {{user.lastName}} \ No newline at end of file diff --git a/api/src/views/single-use-code.hbs b/api/src/views/single-use-code.hbs index 857f09b468..026666eb37 100644 --- a/api/src/views/single-use-code.hbs +++ b/api/src/views/single-use-code.hbs @@ -2,7 +2,6 @@

{{t "singleUseCodeEmail.message" singleUseCodeOptions }}

-

{{t "singleUseCodeEmail.singleUseCode" singleUseCodeOptions}}

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/application.service.spec.ts b/api/test/unit/services/application.service.spec.ts index e0d066e4da..5a0c78d1d1 100644 --- a/api/test/unit/services/application.service.spec.ts +++ b/api/test/unit/services/application.service.spec.ts @@ -233,6 +233,382 @@ export const mockCreateApplicationData = ( } as ApplicationCreate; }; +const detailView = { + applicant: { + select: { + id: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + emailAddress: true, + noEmail: true, + phoneNumber: true, + phoneNumberType: true, + noPhone: true, + workInRegion: true, + applicantAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicantWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + accessibility: { + select: { + id: true, + mobility: true, + vision: true, + hearing: true, + }, + }, + applicationsMailingAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicationsAlternateAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + alternateContact: { + select: { + id: true, + type: true, + otherType: true, + firstName: true, + lastName: true, + agency: true, + phoneNumber: true, + emailAddress: true, + address: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + demographics: { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + }, + preferredUnitTypes: { + select: { + id: true, + name: true, + numBedrooms: true, + }, + }, + listings: { + select: { + id: true, + name: true, + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }, + householdMember: { + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + householdMemberAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + householdMemberWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + userAccounts: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, +}; + +const baseView = { + applicant: { + select: { + id: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + emailAddress: true, + noEmail: true, + phoneNumber: true, + phoneNumberType: true, + noPhone: true, + workInRegion: true, + applicantAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicantWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + accessibility: { + select: { + id: true, + mobility: true, + vision: true, + hearing: true, + }, + }, + applicationsMailingAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicationsAlternateAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + alternateContact: { + select: { + id: true, + type: true, + otherType: true, + firstName: true, + lastName: true, + agency: true, + phoneNumber: true, + emailAddress: true, + address: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + demographics: { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + }, + preferredUnitTypes: { + select: { + id: true, + name: true, + numBedrooms: true, + }, + }, + listings: { + select: { + id: true, + name: true, + jurisdictions: { + select: { + id: true, + name: true, + }, + }, + }, + }, + householdMember: { + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + householdMemberAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + householdMemberWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, +}; + describe('Testing application service', () => { let service: ApplicationService; let prisma: PrismaService; @@ -376,30 +752,7 @@ describe('Testing application service', () => { id: 'example Id', }, include: { - userAccounts: true, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, - alternateContact: { - include: { - address: true, - }, - }, - accessibility: true, - demographics: true, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, - listings: true, - preferredUnitTypes: true, + ...detailView, }, }); }); @@ -427,30 +780,7 @@ describe('Testing application service', () => { id: 'example Id', }, include: { - userAccounts: true, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, - alternateContact: { - include: { - address: true, - }, - }, - accessibility: true, - demographics: true, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, - listings: true, - preferredUnitTypes: true, + ...detailView, }, }); }); @@ -649,29 +979,7 @@ describe('Testing application service', () => { id: 'example Id', }, include: { - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, - alternateContact: { - include: { - address: true, - }, - }, - accessibility: true, - demographics: true, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, - listings: true, - preferredUnitTypes: true, + ...baseView, }, }); }); @@ -822,32 +1130,7 @@ describe('Testing application service', () => { }); expect(prisma.applications.create).toHaveBeenCalledWith({ - include: { - accessibility: true, - applicationsAlternateAddress: true, - applicationsMailingAddress: true, - demographics: true, - listings: true, - preferredUnitTypes: true, - userAccounts: true, - alternateContact: { - include: { - address: true, - }, - }, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, - }, + include: { ...detailView }, data: { contactPreferences: ['example contact preference'], status: ApplicationStatusEnum.submitted, @@ -1108,30 +1391,7 @@ describe('Testing application service', () => { expect(prisma.applications.create).toHaveBeenCalledWith({ include: { - accessibility: true, - applicationsAlternateAddress: true, - applicationsMailingAddress: true, - demographics: true, - listings: true, - preferredUnitTypes: true, - userAccounts: true, - alternateContact: { - include: { - address: true, - }, - }, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, + ...detailView, }, data: { contactPreferences: ['example contact preference'], @@ -1359,30 +1619,7 @@ describe('Testing application service', () => { expect(prisma.applications.update).toHaveBeenCalledWith({ include: { - accessibility: true, - applicationsAlternateAddress: true, - applicationsMailingAddress: true, - demographics: true, - listings: true, - preferredUnitTypes: true, - userAccounts: true, - alternateContact: { - include: { - address: true, - }, - }, - applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, - }, - }, - householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, - }, - }, + ...detailView, }, data: { contactPreferences: ['example contact preference'], @@ -1652,30 +1889,195 @@ describe('Testing application service', () => { id: mockedValue.id, }, include: { - userAccounts: true, applicant: { - include: { - applicantAddress: true, - applicantWorkAddress: true, + select: { + id: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + emailAddress: true, + noEmail: true, + phoneNumber: true, + phoneNumberType: true, + noPhone: true, + workInRegion: true, + applicantAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicantWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + accessibility: { + select: { + id: true, + mobility: true, + vision: true, + hearing: true, + }, + }, + applicationsMailingAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + applicationsAlternateAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, }, }, - applicationsMailingAddress: true, - applicationsAlternateAddress: true, alternateContact: { - include: { - address: true, + select: { + id: true, + type: true, + otherType: true, + firstName: true, + lastName: true, + agency: true, + phoneNumber: true, + emailAddress: true, + address: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + demographics: { + select: { + id: true, + createdAt: true, + updatedAt: true, + ethnicity: true, + gender: true, + sexualOrientation: true, + howDidYouHear: true, + race: true, + }, + }, + preferredUnitTypes: { + select: { + id: true, + name: true, + numBedrooms: true, + }, + }, + listings: { + select: { + id: true, + name: true, + jurisdictions: { + select: { + id: true, + name: true, + }, + }, }, }, - accessibility: true, - demographics: true, householdMember: { - include: { - householdMemberAddress: true, - householdMemberWorkAddress: true, + select: { + id: true, + orderId: true, + firstName: true, + middleName: true, + lastName: true, + birthMonth: true, + birthDay: true, + birthYear: true, + sameAddress: true, + relationship: true, + workInRegion: true, + householdMemberAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + householdMemberWorkAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + }, + }, + userAccounts: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, }, }, - listings: true, - preferredUnitTypes: true, }, }); }); 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/geocoding.service.spec.ts b/api/test/unit/services/geocoding.service.spec.ts index 1fb1c59924..7c0506547d 100644 --- a/api/test/unit/services/geocoding.service.spec.ts +++ b/api/test/unit/services/geocoding.service.spec.ts @@ -6,7 +6,10 @@ import { Address } from '../../../src/dtos/addresses/address.dto'; import { ValidationMethod } from '../../../src/enums/multiselect-questions/validation-method-enum'; import { InputType } from '../../../src/enums/shared/input-type-enum'; import Listing from '../../../src/dtos/listings/listing.dto'; -import { simplifiedDCMap } from '../../../prisma/seed-helpers/map-layer-factory'; +import { + redlinedMap, + simplifiedDCMap, +} from '../../../prisma/seed-helpers/map-layer-factory'; import { FeatureCollection } from '@turf/helpers'; import { ApplicationMultiselectQuestion } from '../../../src/dtos/applications/application-multiselect-question.dto'; import { Application } from '../../../src/dtos/applications/application.dto'; @@ -29,6 +32,7 @@ describe('GeocodingService', () => { longitude: -77.0365, }; const featureCollection = simplifiedDCMap as unknown as FeatureCollection; + const featureCollection2 = redlinedMap as unknown as FeatureCollection; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -107,14 +111,25 @@ describe('GeocodingService', () => { }); it("should return 'true' if address is within layer", () => { expect(service.verifyLayers(address, featureCollection)).toBe(true); + expect( + service.verifyLayers( + { + ...address, + latitude: 37.870318963458324, + longitude: -122.30141799736678, + }, + featureCollection2, + ), + ).toBe(true); }); - it("should return 'false' if address is within layer", () => { + it("should return 'false' if address is not within layer", () => { expect( service.verifyLayers( { ...address, latitude: 39.284205, longitude: -76.621698 }, featureCollection, ), ).toBe(false); + expect(service.verifyLayers(address, featureCollection2)).toBe(false); }); }); 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/api/yarn.lock b/api/yarn.lock index c7fd783cd7..47c2c1a949 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -1102,6 +1102,11 @@ dependencies: tslib "2.6.2" +"@nestjs/throttler@^5.1.2": + version "5.1.2" + resolved "https://registry.yarnpkg.com/@nestjs/throttler/-/throttler-5.1.2.tgz#dc65634153c8b887329b1cc6061db2e556517dcb" + integrity sha512-60MqhSLYUqWOgc38P6C6f76JIpf6mVjly7gpuPBCKtVd0p5e8Fq855j7bJuO4/v25vgaOo1OdVs0U1qtgYioGw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" diff --git a/shared-helpers/index.ts b/shared-helpers/index.ts index 50f4aa02d6..d111acb81d 100644 --- a/shared-helpers/index.ts +++ b/shared-helpers/index.ts @@ -26,5 +26,7 @@ export * from "./src/views/summaryTables" export * from "./src/views/forgot-password/FormForgotPassword" export * from "./src/views/layout/ExygyFooter" export * from "./src/views/sign-in/FormSignIn" +export * from "./src/views/sign-in/FormSignInDefault" export * from "./src/views/sign-in/FormSignInErrorBox" +export * from "./src/views/sign-in/FormSignInPwdless" export * from "./src/views/sign-in/ResendConfirmationModal" diff --git a/shared-helpers/src/auth/AuthContext.ts b/shared-helpers/src/auth/AuthContext.ts index 6bf148db37..b616a59f10 100644 --- a/shared-helpers/src/auth/AuthContext.ts +++ b/shared-helpers/src/auth/AuthContext.ts @@ -31,6 +31,7 @@ import { UserCreate, UserService, serviceOptions, + SuccessDTO, } from "../types/backend-swagger" import { getListingRedirectUrl } from "../utilities/getListingRedirectUrl" @@ -74,6 +75,7 @@ type ContextProps = { mfaType: MfaType, phoneNumber?: string ) => Promise + requestSingleUseCode: (email: string) => Promise loginViaSingleUseCode: (email: string, singleUseCode: string) => Promise } @@ -261,6 +263,8 @@ export const AuthProvider: FunctionComponent = ({ child if (profile) { dispatch(saveProfile(profile)) return profile + } else { + throw Error("User cannot log in") } } return undefined @@ -360,6 +364,16 @@ export const AuthProvider: FunctionComponent = ({ child dispatch(stopLoading()) } }, + requestSingleUseCode: async (email) => { + dispatch(startLoading()) + try { + return await userService?.requestSingleUseCode({ + body: { email }, + }) + } finally { + dispatch(stopLoading()) + } + }, } return createElement(AuthContext.Provider, { value: contextValues }, children) } diff --git a/shared-helpers/src/auth/catchNetworkError.ts b/shared-helpers/src/auth/catchNetworkError.ts index e61114a421..a3ee02e457 100644 --- a/shared-helpers/src/auth/catchNetworkError.ts +++ b/shared-helpers/src/auth/catchNetworkError.ts @@ -29,6 +29,7 @@ export type NetworkErrorReset = () => void export enum NetworkErrorMessage { PasswordOutdated = "but password is no longer valid", MfaUnauthorized = "mfaUnauthorized", + SingleUseCodeUnauthorized = "singleUseCodeUnauthorized", } /** @@ -54,6 +55,14 @@ export const useCatchNetworkError = () => { }), error, }) + } else if (message === NetworkErrorMessage.SingleUseCodeUnauthorized) { + setNetworkError({ + title: t("authentication.signIn.pwdless.error"), + description: t("authentication.signIn.afterFailedAttempts", { + count: error?.response?.data?.failureCountRemaining || 5, + }), + error, + }) } else { setNetworkError({ title: t("authentication.signIn.enterValidEmailAndPassword"), diff --git a/shared-helpers/src/locales/es.json b/shared-helpers/src/locales/es.json index 9f612aacc5..4d09c30aef 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -387,8 +387,8 @@ "authentication.signIn.changeYourPassword": "Puede cambiar su contraseña", "authentication.signIn.enterLoginEmail": "Por favor, escriba su correo electrónico de inicio de sesión", "authentication.signIn.enterLoginPassword": "Por favor, escriba su contraseña de inicio de sesión", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Por favor, escriba un código válido.", - "authentication.signIn.enterValidEmailAndPassword": "Por favor, escriba un correo electrónico y una contraseña válidos.", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Por favor, escriba un código válido", + "authentication.signIn.enterValidEmailAndPassword": "Por favor, escriba un correo electrónico y una contraseña válidos", "authentication.signIn.errorGenericMessage": "Por favor inténtelo de nuevo, o comuníquese con servicio al cliente para recibir asistencia.", "authentication.signIn.error": "Hubo un error cuando usted inició sesión", "authentication.signIn.forgotPassword": "Olvidé la contraseña", diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 63ec52d092..c2dc4a39be 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -15,9 +15,12 @@ "account.viewApplications": "View applications", "account.myApplicationsSubtitle": "See lottery dates and listings for properties for which you've applied", "account.noApplications": "It looks like you haven't applied to any listings yet.", + "account.reviewTerms": "Review Terms of Use", + "account.reviewTermsHelper": "You must accept the terms of use before creating an account.", "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.", @@ -37,6 +40,18 @@ "account.settings.placeholders.year": "YYYY", "account.settings.update": "Update", "account.settings.iconTitle": "generic user", + "account.pwdless.code": "Your code", + "account.pwdless.createMessage": "We sent a code to %{email} to finish signing up. Be aware, the code will expire in 5 minutes.", + "account.pwdless.loginMessage": "If there is an account made with %{email}, we’ll send a code within 5 minutes. If you don’t receive a code, sign in with your password and confirm your email address under account settings.", + "account.pwdless.codeNewAlert": "A new code has been sent to %{email}. Be aware, the code will expire in 5 minutes.", + "account.pwdless.continue": "Continue", + "account.pwdless.notReceived": "Didn't receive your code?", + "account.pwdless.resend": "Resend", + "account.pwdless.resendCode": "Resend Code", + "account.pwdless.resendCodeButton": "Resend the code", + "account.pwdless.resendCodeHelper": "If there is an account made with %{email}, we’ll send a new code. Be aware, the code will expire in 5 minutes.", + "account.pwdless.signInWithYourPassword": "Sign in with your password", + "account.pwdless.verifyTitle": "Verify that it's you", "alert.maintenance": "This site is undergoing scheduled maintenance. We apologize for any inconvenience.", "application.ada.hearing": "For Hearing Impairments", "application.ada.label": "ADA Accessible Units", @@ -197,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…", @@ -534,14 +553,20 @@ "authentication.signIn.changeYourPassword": "You can change your password", "authentication.signIn.enterLoginEmail": "Please enter your login email", "authentication.signIn.enterLoginPassword": "Please enter your login password", - "authentication.signIn.enterValidEmailAndPassword": "Please enter a valid email and password.", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Please enter a valid code.", + "authentication.signIn.enterValidEmailAndPassword": "Please enter a valid email and password", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Please enter a valid code", "authentication.signIn.error": "There was an error signing you in", "authentication.signIn.errorGenericMessage": "Please try again, or contact support for help.", "authentication.signIn.forgotPassword": "Forgot password?", "authentication.signIn.loginError": "Please enter a valid email address", "authentication.signIn.passwordError": "Please enter a valid password", "authentication.signIn.passwordOutdated": "Your password has expired. Please reset your password.", + "authentication.signIn.pwdless.createAccountCopy": "Sign up quicky with no need to remember any passwords.", + "authentication.signIn.pwdless.emailHelperText": "Enter your email and we'll send you a code to sign in.", + "authentication.signIn.pwdless.error": "The code you've used is invalid or expired.", + "authentication.signIn.pwdless.getCode": "Get code to sign in", + "authentication.signIn.pwdless.useCode": "Get a code instead", + "authentication.signIn.pwdless.usePassword": "Use your password instead", "authentication.signIn.success": "Welcome back, %{name}!", "authentication.signIn.youHaveToWait": "You’ll have to wait 30 minutes since the last failed attempt before trying again.", "authentication.signIn.yourAccountIsNotConfirmed": "Your account is not confirmed", @@ -921,6 +946,7 @@ "t.email": "Email", "t.emailAddressPlaceholder": "you@myemail.com", "t.filter": "Filter", + "t.finish": "Finish", "t.floor": "floor", "t.floors": "floors", "t.getDirections": "Get Directions", @@ -934,6 +960,7 @@ "t.lastUpdated": "Last Updated", "t.less": "Less", "t.letter": "Letter", + "t.loading": "Loading", "t.loginIsRequired": "Login is required to view this page.", "t.menu": "Menu", "t.minimumIncome": "Minimum Income", diff --git a/shared-helpers/src/locales/vi.json b/shared-helpers/src/locales/vi.json index 435c32d35c..e0bc42392d 100644 --- a/shared-helpers/src/locales/vi.json +++ b/shared-helpers/src/locales/vi.json @@ -373,6 +373,7 @@ "authentication.createAccount.password": "Mật khẩu", "authentication.createAccount.reEnterEmail": "Nhập lại địa chỉ Email", "authentication.createAccount.reEnterPassword": "Nhập lại mật khẩu của bạn", + "authentication.createAccount.resendAnEmailTo": "Gửi lại email đến", "authentication.createAccount.resendEmailInfo": "Vui lòng nhấp vào liên kết trong email mà chúng tôi gửi cho quý vị trong vòng 24 giờ để hoàn tất việc tạo tài khoản.", "authentication.createAccount.resendTheEmail": "Gửi lại Email", @@ -387,8 +388,8 @@ "authentication.signIn.changeYourPassword": "Quý vị có thể đổi mật khẩu", "authentication.signIn.enterLoginEmail": "Vui lòng nhập email đăng nhập của quý vị", "authentication.signIn.enterLoginPassword": "Vui lòng nhập mật khẩu đăng nhập của quý vị", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Vui lòng nhập mã hợp lệ.", - "authentication.signIn.enterValidEmailAndPassword": "Vui lòng nhập email và mật khẩu hợp lệ.", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "Vui lòng nhập mã hợp lệ", + "authentication.signIn.enterValidEmailAndPassword": "Vui lòng nhập email và mật khẩu hợp lệ", "authentication.signIn.errorGenericMessage": "Vui lòng thử lại hoặc liên lạc với bộ phận hỗ trợ để được trợ giúp.", "authentication.signIn.error": "Đã xảy ra lỗi khi quý vị đăng nhập", "authentication.signIn.forgotPassword": "Quên mật khẩu", diff --git a/shared-helpers/src/locales/zh.json b/shared-helpers/src/locales/zh.json index b5b4f6f8dc..5b7314b446 100644 --- a/shared-helpers/src/locales/zh.json +++ b/shared-helpers/src/locales/zh.json @@ -387,8 +387,8 @@ "authentication.signIn.changeYourPassword": "您可以變更密碼", "authentication.signIn.enterLoginEmail": "請輸入您的登入電子郵件", "authentication.signIn.enterLoginPassword": "請輸入您的登入密碼", - "authentication.signIn.enterValidEmailAndPasswordAndMFA": "請輸入有效的代碼。", - "authentication.signIn.enterValidEmailAndPassword": "請輸入有效的電子郵件和密碼。", + "authentication.signIn.enterValidEmailAndPasswordAndMFA": "請輸入有效的代碼", + "authentication.signIn.enterValidEmailAndPassword": "請輸入有效的電子郵件和密碼", "authentication.signIn.errorGenericMessage": "請再試一次,或聯絡支援人員尋求協助。", "authentication.signIn.error": "您在登入時出現錯誤", "authentication.signIn.forgotPassword": "忘記密碼", 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/shared-helpers/src/views/sign-in/FormSignIn.module.scss b/shared-helpers/src/views/sign-in/FormSignIn.module.scss index 3bf53b4c23..b8bad21b91 100644 --- a/shared-helpers/src/views/sign-in/FormSignIn.module.scss +++ b/shared-helpers/src/views/sign-in/FormSignIn.module.scss @@ -17,3 +17,29 @@ margin-top: var(--seeds-s6); width: 100%; } + +.sign-in-email-input { + margin-bottom: var(--seeds-s6); +} + +.sign-in-password-input { + margin-bottom: var(--seeds-s3); +} + +.sign-in-action { + margin-top: var(--seeds-s6); +} + +.create-account-copy { + padding-bottom: var(--seeds-s6); + color: var(--seeds-text-color-light); + font-size: var(--seeds-type-label-size); +} + +.pwdless-header { + margin-bottom: var(--seeds-s3); +} + +.default-header { + margin-bottom: var(--seeds-s6); +} diff --git a/shared-helpers/src/views/sign-in/FormSignIn.tsx b/shared-helpers/src/views/sign-in/FormSignIn.tsx index b46b05434a..6204feb755 100644 --- a/shared-helpers/src/views/sign-in/FormSignIn.tsx +++ b/shared-helpers/src/views/sign-in/FormSignIn.tsx @@ -1,27 +1,24 @@ -import React, { useContext } from "react" +import React from "react" import type { UseFormMethods } from "react-hook-form" -import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" +import { useRouter } from "next/router" +import { t } from "@bloom-housing/ui-components" import { Button, Heading } from "@bloom-housing/ui-seeds" import { CardSection } from "@bloom-housing/ui-seeds/src/blocks/Card" import { FormSignInErrorBox } from "./FormSignInErrorBox" import { NetworkStatus } from "../../auth/catchNetworkError" import { BloomCard } from "../components/BloomCard" -import { useRouter } from "next/router" import { getListingRedirectUrl } from "../../utilities/getListingRedirectUrl" import styles from "./FormSignIn.module.scss" export type FormSignInProps = { control: FormSignInControl - onSubmit: (data: FormSignInValues) => void networkStatus: NetworkStatus showRegisterBtn?: boolean + children: React.ReactNode } export type FormSignInControl = { errors: UseFormMethods["errors"] - handleSubmit: UseFormMethods["handleSubmit"] - register: UseFormMethods["register"] - watch: UseFormMethods["watch"] } export type FormSignInValues = { @@ -30,18 +27,13 @@ export type FormSignInValues = { } const FormSignIn = ({ - onSubmit, + children, networkStatus, showRegisterBtn, - control: { errors, register, handleSubmit }, + control: { errors }, }: FormSignInProps) => { - const onError = () => { - window.scrollTo(0, 0) - } - const { LinkComponent } = useContext(NavigationContext) const router = useRouter() const listingIdRedirect = router.query?.listingId as string - const forgetPasswordURL = getListingRedirectUrl(listingIdRedirect, "/forgot-password") const createAccountUrl = getListingRedirectUrl(listingIdRedirect, "/create-account") return ( @@ -53,49 +45,23 @@ const FormSignIn = ({ errorMessageId={"main-sign-in"} className={styles["sign-in-error-container"]} /> - -
- - - -
- -
- -
+ {children} {showRegisterBtn && ( - + {t("authentication.createAccount.noAccount")} - + {process.env.showPwdless && ( +
+ {t("authentication.signIn.pwdless.createAccountCopy")} +
+ )} diff --git a/shared-helpers/src/views/sign-in/FormSignInDefault.tsx b/shared-helpers/src/views/sign-in/FormSignInDefault.tsx new file mode 100644 index 0000000000..783d942c49 --- /dev/null +++ b/shared-helpers/src/views/sign-in/FormSignInDefault.tsx @@ -0,0 +1,76 @@ +import React, { useContext } from "react" +import { useRouter } from "next/router" +import type { UseFormMethods } from "react-hook-form" +import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" +import { Button } from "@bloom-housing/ui-seeds" +import { getListingRedirectUrl } from "../../utilities/getListingRedirectUrl" +import styles from "./FormSignIn.module.scss" + +export type FormSignInDefaultProps = { + control: FormSignInDefaultControl + onSubmit: (data: FormSignInDefaultValues) => void +} + +export type FormSignInDefaultValues = { + email: string + password: string +} + +export type FormSignInDefaultControl = { + errors: UseFormMethods["errors"] + handleSubmit: UseFormMethods["handleSubmit"] + register: UseFormMethods["register"] +} + +const FormSignInDefault = ({ + onSubmit, + control: { errors, register, handleSubmit }, +}: FormSignInDefaultProps) => { + const onError = () => { + window.scrollTo(0, 0) + } + const { LinkComponent } = useContext(NavigationContext) + const router = useRouter() + const listingIdRedirect = router.query?.listingId as string + const forgetPasswordURL = getListingRedirectUrl(listingIdRedirect, "/forgot-password") + + return ( +
+ + + +
+ +
+ + ) +} + +export { FormSignInDefault as default, FormSignInDefault } diff --git a/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx b/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx new file mode 100644 index 0000000000..03acb314ae --- /dev/null +++ b/shared-helpers/src/views/sign-in/FormSignInPwdless.tsx @@ -0,0 +1,93 @@ +import React, { useContext } from "react" +import { useRouter } from "next/router" +import type { UseFormMethods } from "react-hook-form" +import { Field, Form, NavigationContext, t } from "@bloom-housing/ui-components" +import { Button } from "@bloom-housing/ui-seeds" +import { getListingRedirectUrl } from "../../utilities/getListingRedirectUrl" +import styles from "./FormSignIn.module.scss" + +export type FormSignInPwdlessProps = { + control: FormSignInPwdlessControl + onSubmit: (data: FormSignInPwdlessValues) => void + useCode: boolean + setUseCode: React.Dispatch> +} + +export type FormSignInPwdlessValues = { + email: string + password: string +} + +export type FormSignInPwdlessControl = { + errors: UseFormMethods["errors"] + handleSubmit: UseFormMethods["handleSubmit"] + register: UseFormMethods["register"] +} + +const FormSignInPwdless = ({ + onSubmit, + control: { errors, register, handleSubmit }, + useCode, + setUseCode, +}: FormSignInPwdlessProps) => { + const onError = () => { + window.scrollTo(0, 0) + } + const { LinkComponent } = useContext(NavigationContext) + const router = useRouter() + const listingIdRedirect = router.query?.listingId as string + const forgetPasswordURL = getListingRedirectUrl(listingIdRedirect, "/forgot-password") + + return ( +
+ + + {!useCode && ( + <> + + + + )} +
+ +
+
+ +
+ + ) +} + +export { FormSignInPwdless as default, FormSignInPwdless } diff --git a/sites/partners/src/pages/api/adapter/[...backendUrl].ts b/sites/partners/src/pages/api/adapter/[...backendUrl].ts index 93fe79cf2f..b50e2505dc 100644 --- a/sites/partners/src/pages/api/adapter/[...backendUrl].ts +++ b/sites/partners/src/pages/api/adapter/[...backendUrl].ts @@ -24,6 +24,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { jurisdictionName: req.headers.jurisdictionname, language: req.headers.language, appUrl: req.headers.appurl, + "x-forwarded-for": req.headers["x-forwarded-for"] || "", }, paramsSerializer: (params) => { return qs.stringify(params) diff --git a/sites/partners/src/pages/sign-in.tsx b/sites/partners/src/pages/sign-in.tsx index 267d88ee64..16d4793f04 100644 --- a/sites/partners/src/pages/sign-in.tsx +++ b/sites/partners/src/pages/sign-in.tsx @@ -8,6 +8,7 @@ import { AuthContext, FormSignIn, ResendConfirmationModal, + FormSignInDefault, } from "@bloom-housing/shared-helpers" import { useMutate, t } from "@bloom-housing/ui-components" import FormsLayout from "../layouts/forms" @@ -124,16 +125,6 @@ const SignIn = () => { formToRender = ( { setConfirmationStatusMessage(undefined) }, }} - /> + control={{ errors }} + > + + ) } else if (renderStep === EnumRenderStep.mfaType) { formToRender = ( diff --git a/sites/public/.env.template b/sites/public/.env.template index efa2305616..44d0714a81 100644 --- a/sites/public/.env.template +++ b/sites/public/.env.template @@ -27,3 +27,4 @@ MAINTENANCE_WINDOW= # feature toggles SHOW_MANDATED_ACCOUNTS=FALSE +SHOW_PWDLESS=FALSE diff --git a/sites/public/next.config.js b/sites/public/next.config.js index 543b31388e..f8f673d1f5 100644 --- a/sites/public/next.config.js +++ b/sites/public/next.config.js @@ -42,6 +42,7 @@ module.exports = withBundleAnalyzer({ cacheRevalidate: process.env.CACHE_REVALIDATE ? Number(process.env.CACHE_REVALIDATE) : 60, cloudinaryCloudName: process.env.CLOUDINARY_CLOUD_NAME, showMandatedAccounts: process.env.SHOW_MANDATED_ACCOUNTS === "TRUE", + showPwdless: process.env.SHOW_PWDLESS === "TRUE", maintenanceWindow: process.env.MAINTENANCE_WINDOW, }, i18n: { diff --git a/sites/public/src/components/account/AccountCard.module.scss b/sites/public/src/components/account/AccountCard.module.scss deleted file mode 100644 index 485bfb787a..0000000000 --- a/sites/public/src/components/account/AccountCard.module.scss +++ /dev/null @@ -1,25 +0,0 @@ -.account-card { - --card-header-padding-inline-desktop: var(--seeds-s12); - --card-header-padding-inline-mobile: var(--seeds-s4); - --card-content-padding-inline-mobile: var(--seeds-s4); - --card-border-width: 0rem; - --card-divider-width: var(--seeds-border-1); - width: 100%; - @media (max-width: theme("screens.sm")) { - --card-spacing-lg: var(--seeds-s6); - --card-border-radius: 0rem; - } -} - -.acccount-card-icon { - margin-bottom: var(--seeds-s3); -} - -.account-card-heading-group { - --subheading-margin: var(--seeds-s3); - --heading-margin: var(--seeds-s3); -} - -.account-card-heading { - margin-top: var(--seeds-s3); -} diff --git a/sites/public/src/components/account/AccountCard.tsx b/sites/public/src/components/account/AccountCard.tsx deleted file mode 100644 index 588ac4f002..0000000000 --- a/sites/public/src/components/account/AccountCard.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Heading, HeadingGroup, Icon } from "@bloom-housing/ui-seeds" -import Card from "@bloom-housing/ui-seeds/src/blocks/Card" -import React from "react" -import styles from "./AccountCard.module.scss" -import { CustomIconMap, CustomIconType } from "../shared/CustomIconMap" - -interface AccountCardProps { - iconSymbol: CustomIconType - title: string - subtitle?: string - children: React.ReactElement - id?: string - divider?: "flush" | "inset" - headingPriority?: 1 | 2 | 3 | 4 | 5 | 6 - className?: string -} - -const AccountCard = (props: AccountCardProps) => { - const classNames = [styles["account-card"]] - if (props.className) classNames.push(props.className) - - const customIcon = CustomIconMap[props.iconSymbol] - - return ( - - - - {customIcon} - - {props.subtitle ? ( - - ) : ( - - {props.title} - - )} - - {props.children} - - ) -} - -export { AccountCard as default, AccountCard } 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/api/adapter/[...backendUrl].ts b/sites/public/src/pages/api/adapter/[...backendUrl].ts index 0e32a30d3b..818d9d49ec 100644 --- a/sites/public/src/pages/api/adapter/[...backendUrl].ts +++ b/sites/public/src/pages/api/adapter/[...backendUrl].ts @@ -21,6 +21,7 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { jurisdictionName: req.headers.jurisdictionname, language: req.headers.language, appUrl: req.headers.appurl, + "x-forwarded-for": req.headers["x-forwarded-for"] || "", }, paramsSerializer: (params) => { return qs.stringify(params) diff --git a/sites/public/src/pages/applications/contact/address.tsx b/sites/public/src/pages/applications/contact/address.tsx index c735d73960..f405a85243 100644 --- a/sites/public/src/pages/applications/contact/address.tsx +++ b/sites/public/src/pages/applications/contact/address.tsx @@ -563,13 +563,13 @@ const ApplicationAddress = () => { { /> {
{ />