diff --git a/.gitignore b/.gitignore index 0d11ce39f4d..281b61b0374 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ yarn-error.log* # Complied Typescript dist +*.tsbuildinfo # Runtime data pids diff --git a/api/.env.template b/api/.env.template index bca757d4812..3a452a4b97f 100644 --- a/api/.env.template +++ b/api/.env.template @@ -124,3 +124,10 @@ S3_BUCKET= S3_ACCESS_TOKEN= # Secret key for the service account to the s3 bucket S3_SECRET_TOKEN= + +# Activity Log Config +# how many days until activity log entries expire +ACTIVITY_LOG_RETENTION_DAYS=30 +# controls the repetition of the activity log cleanup cron job +ACTIVITY_LOG_CLEANUP_CRON_STRING="0 2 * * *" + diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index 77d81b0b2e3..ee9d6c23adc 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -53,6 +53,7 @@ import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; import { ApiKeyGuard } from '../guards/api-key.guard'; import { UserDeleteDTO } from '../dtos/users/user-delete.dto'; +import { UserAuditDto } from '../dtos/users/user-audit.dto'; @Controller('user') @ApiTags('user') @@ -285,6 +286,19 @@ export class UserController { ); } + @Get(':id/audit') + @ApiOperation({ + summary: 'Get user audit log', + operationId: 'getAuditLog', + }) + @ApiOkResponse({ type: UserAuditDto }) + @UseGuards(JwtAuthGuard, AdminOrJurisdictionalAdminGuard) + async getAuditLog( + @Param('id', new ParseUUIDPipe({ version: '4' })) userId: string, + ): Promise { + return await this.userService.getAuditLog(userId); + } + @Get(`:id`) @ApiOperation({ summary: 'Get user by id', diff --git a/api/src/dtos/users/user-audit.dto.ts b/api/src/dtos/users/user-audit.dto.ts new file mode 100644 index 00000000000..5f530219dc9 --- /dev/null +++ b/api/src/dtos/users/user-audit.dto.ts @@ -0,0 +1,76 @@ +import { Expose, Type } from 'class-transformer'; +import { IsArray, IsDate, IsString, ValidateNested } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ActivityLogAction } from '../../enums/shared/activity-log-action-enum'; + +export class AuditLogEntryDto { + @Expose() + @ApiProperty() + @Type(() => Date) + @IsDate() + createdAt: Date; + + @Expose() + @ApiProperty({ enum: ActivityLogAction, enumName: 'ActivityLogAction' }) + @IsString() + action: ActivityLogAction; + + @Expose() + @ApiPropertyOptional() + metadata?: any; +} + +export class AppSubmissionDto { + @Expose() + @ApiProperty() + @Type(() => Date) + @IsDate() + submissionDate: Date; + + @Expose() + @ApiProperty() + @IsString() + listingName: string; + + @Expose() + @ApiProperty() + @IsString() + confirmationCode: string; + + @Expose() + @ApiPropertyOptional() + @IsString() + listingId?: string; + + @Expose() + @ApiPropertyOptional() + @IsString() + applicationId?: string; + + @Expose() + @ApiPropertyOptional() + ranking?: number; +} + +export class UserAuditDto { + @Expose() + @ApiProperty({ type: AuditLogEntryDto, isArray: true }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AuditLogEntryDto) + loginAttempts: AuditLogEntryDto[]; + + @Expose() + @ApiProperty({ type: AppSubmissionDto, isArray: true }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AppSubmissionDto) + appSubmissions: AppSubmissionDto[]; + + @Expose() + @ApiProperty({ type: AuditLogEntryDto, isArray: true }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AuditLogEntryDto) + passwordChanges: AuditLogEntryDto[]; +} diff --git a/api/src/enums/shared/activity-log-action-enum.ts b/api/src/enums/shared/activity-log-action-enum.ts new file mode 100644 index 00000000000..65026cf8503 --- /dev/null +++ b/api/src/enums/shared/activity-log-action-enum.ts @@ -0,0 +1,5 @@ +export enum ActivityLogAction { + login = 'login', + login_failed = 'login_failed', + password_update = 'password_update', +} diff --git a/api/src/modules/cron-job.module.ts b/api/src/modules/cron-job.module.ts index 35b537411fd..7c140bf513f 100644 --- a/api/src/modules/cron-job.module.ts +++ b/api/src/modules/cron-job.module.ts @@ -1,12 +1,19 @@ import { Logger, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { SchedulerRegistry } from '@nestjs/schedule'; import { PrismaModule } from './prisma.module'; import { CronJobService } from '../services/cron-job.service'; +import { ActivityLogCleanupService } from '../services/activity-log-cleanup.service'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, ConfigModule], controllers: [], - providers: [CronJobService, Logger, SchedulerRegistry], + providers: [ + CronJobService, + ActivityLogCleanupService, + Logger, + SchedulerRegistry, + ], exports: [CronJobService], }) export class CronJobModule {} diff --git a/api/src/passports/mfa.strategy.ts b/api/src/passports/mfa.strategy.ts index 186e098eff1..069f9621126 100644 --- a/api/src/passports/mfa.strategy.ts +++ b/api/src/passports/mfa.strategy.ts @@ -9,6 +9,7 @@ import { import { User } from '../dtos/users/user.dto'; import { PrismaService } from '../services/prisma.service'; import { mapTo } from '../utilities/mapTo'; +import { ActivityLogAction } from '../enums/shared/activity-log-action-enum'; import { isPasswordOutdated, isPasswordValid, @@ -53,6 +54,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { }, }); if (!rawUser) { + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + metadata: { + email: dto.email, + reason: 'user_not_found', + }, + }, + }); throw new UnauthorizedException( `user ${dto.email} attempted to log in, but does not exist`, ); @@ -72,6 +83,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { ) ) { // if password TTL is expired + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + userId: rawUser.id, + metadata: { + reason: 'password_outdated', + }, + }, + }); throw new UnauthorizedException( `user ${rawUser.id} attempted to login, but password is no longer valid`, ); @@ -81,6 +102,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { rawUser.failedLoginAttemptsCount + 1, rawUser.id, ); + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + userId: rawUser.id, + metadata: { + reason: 'invalid_password', + }, + }, + }); throw new UnauthorizedException({ failureCountRemaining: Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) - @@ -88,6 +119,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { }); } else if (!rawUser.confirmedAt) { // if user is not confirmed already + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + userId: rawUser.id, + metadata: { + reason: 'not_confirmed', + }, + }, + }); throw new UnauthorizedException( `user ${rawUser.id} attempted to login, but is not confirmed`, ); @@ -96,6 +137,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { if (!rawUser.mfaEnabled) { // if user is not an mfaEnabled user await this.updateStoredUser(null, null, null, 0, rawUser.id); + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login, + userId: rawUser.id, + metadata: { + method: 'password', + }, + }, + }); return mapTo(User, rawUser); } @@ -139,6 +190,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { rawUser.failedLoginAttemptsCount, rawUser.id, ); + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + userId: rawUser.id, + metadata: { + reason: 'invalid_mfa_code', + }, + }, + }); throw new UnauthorizedException({ message: 'mfaUnauthorized', failureCountRemaining: @@ -162,6 +223,16 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { rawUser.failedLoginAttemptsCount, rawUser.id, ); + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login, + userId: rawUser.id, + metadata: { + method: dto.mfaType || 'mfa', + }, + }, + }); return mapTo(User, rawUser); } diff --git a/api/src/passports/single-use-code.strategy.ts b/api/src/passports/single-use-code.strategy.ts index 7f4251bee14..8acbfeb31e4 100644 --- a/api/src/passports/single-use-code.strategy.ts +++ b/api/src/passports/single-use-code.strategy.ts @@ -13,6 +13,7 @@ import { mapTo } from '../utilities/mapTo'; import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; import { LoginViaSingleUseCode } from '../dtos/auth/login-single-use-code.dto'; import { OrderByEnum } from '../enums/shared/order-by-enum'; +import { ActivityLogAction } from '../enums/shared/activity-log-action-enum'; import { checkUserLockout, singleUseCodePresent, @@ -81,12 +82,32 @@ export class SingleUseCodeStrategy extends PassportStrategy( }, }); if (!rawUser) { + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + metadata: { + email: dto.email, + reason: 'user_not_found', + }, + }, + }); throw new UnauthorizedException( `user ${dto.email} attempted to log in, but does not exist`, ); } if (!rawUser.agreedToTermsOfService && !dto.agreedToTermsOfService) { + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + userId: rawUser.id, + metadata: { + reason: 'terms_of_service_not_accepted', + }, + }, + }); throw new BadRequestException( `User ${rawUser.id} has not accepted the terms of service`, ); @@ -138,6 +159,16 @@ export class SingleUseCodeStrategy extends PassportStrategy( rawUser.failedLoginAttemptsCount, rawUser.id, ); + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + userId: rawUser.id, + metadata: { + reason: 'invalid_single_use_code', + }, + }, + }); throw new UnauthorizedException({ message: 'singleUseCodeUnauthorized', failureCountRemaining: @@ -156,6 +187,16 @@ export class SingleUseCodeStrategy extends PassportStrategy( rawUser.failedLoginAttemptsCount, rawUser.id, ); + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.login, + userId: rawUser.id, + metadata: { + method: 'single_use_code', + }, + }, + }); return mapTo(User, rawUser); } diff --git a/api/src/services/activity-log-cleanup.service.ts b/api/src/services/activity-log-cleanup.service.ts new file mode 100644 index 00000000000..fc512dfad2c --- /dev/null +++ b/api/src/services/activity-log-cleanup.service.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from './prisma.service'; +import { CronJobService } from './cron-job.service'; +import dayjs from 'dayjs'; +import { SuccessDTO } from '../dtos/shared/success.dto'; + +@Injectable() +export class ActivityLogCleanupService implements OnModuleInit { + constructor( + private prisma: PrismaService, + private cronJobService: CronJobService, + private configService: ConfigService, + @Inject(Logger) + private logger = new Logger(ActivityLogCleanupService.name), + ) {} + + async onModuleInit() { + this.logger.log('Registering ActivityLog cleanup cron job'); + // Default to daily at 2am if not specified + await this.cronJobService.startCronJob( + 'activity-log-cleanup', + this.configService.get( + 'ACTIVITY_LOG_CLEANUP_CRON_STRING', + '0 2 * * *', + ), + () => this.cleanup(), + ); + } + + async cleanup(): Promise { + const retentionDays = this.configService.get( + 'ACTIVITY_LOG_RETENTION_DAYS', + ); + if (isNaN(retentionDays) || retentionDays <= 0) { + this.logger.warn( + 'ACTIVITY_LOG_RETENTION_DAYS is not set or invalid. Skipping cleanup.', + ); + return { success: false }; + } + + this.logger.log( + `Cleaning up ActivityLog entries older than ${retentionDays} days`, + ); + await this.cronJobService.markCronJobAsStarted('activity-log-cleanup'); + + const result = await this.prisma.activityLog.deleteMany({ + where: { + module: 'auth', + createdAt: { + lt: dayjs().subtract(retentionDays, 'days').toDate(), + }, + }, + }); + + this.logger.log(`Deleted ${result.count} ActivityLog entries`); + return { success: true }; + } +} diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts index 5f45cdb24f0..acd8d3168a2 100644 --- a/api/src/services/auth.service.ts +++ b/api/src/services/auth.service.ts @@ -15,6 +15,7 @@ import { RequestMfaCode } from '../dtos/mfa/request-mfa-code.dto'; import { IdDTO } from '../dtos/shared/id.dto'; import { SuccessDTO } from '../dtos/shared/success.dto'; import { User } from '../dtos/users/user.dto'; +import { ActivityLogAction } from '../enums/shared/activity-log-action-enum'; import { MfaType } from '../enums/mfa/mfa-type-enum'; import { UserViews } from '../enums/user/view-enum'; import { getSingleUseCode } from '../utilities/get-single-use-code'; @@ -124,7 +125,7 @@ export class AuthService { message: `The ReCaptcha CreateAssessment call failed because the token was: ${response.tokenProperties.invalidReason}`, }); } - if (response.tokenProperties.action === 'login') { + if (response.tokenProperties.action === ActivityLogAction.login) { response.riskAnalysis.reasons.forEach((reason) => { console.log(reason); }); @@ -352,6 +353,13 @@ export class AuthService { id: user.id, }, }); + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.password_update, + userId: user.id, + }, + }); return await this.setCredentials( res, mapTo(User, user), @@ -396,6 +404,15 @@ export class AuthService { id: foundUser.id, }, }); + if (dto.password) { + await this.prisma.activityLog.create({ + data: { + module: 'auth', + action: ActivityLogAction.password_update, + userId: foundUser.id, + }, + }); + } return await this.setCredentials( res, mapTo(User, updatedUser), diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index c0d8a0179e8..ce87a395ce5 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -52,6 +52,8 @@ import { UserFavoriteListing } from '../dtos/users/user-favorite-listing.dto'; import { ModificationEnum } from '../enums/shared/modification-enum'; import { CronJobService } from './cron-job.service'; import { ApplicationService } from './application.service'; +import { UserAuditDto } from '../dtos/users/user-audit.dto'; +import { ActivityLogAction } from '../enums/shared/activity-log-action-enum'; /* this is the service for users @@ -1190,4 +1192,72 @@ export class UserService { success: true, }; } + + /* + this will return an audit log for a specific user + */ + async getAuditLog(userId: string): Promise { + const user = await this.findUserOrError({ userId }); + + const loginAttempts = await this.prisma.activityLog.findMany({ + where: { + userId: user.id, + module: 'auth', + action: { + in: [ActivityLogAction.login, ActivityLogAction.login_failed], + }, + }, + orderBy: { createdAt: 'desc' }, + }); + + const appSubmissions = await this.prisma.applications.findMany({ + where: { + OR: [{ userId: user.id }, { applicant: { emailAddress: user.email } }], + deletedAt: null, + }, + include: { + listings: { + select: { + name: true, + id: true, + }, + }, + applicationLotteryPositions: { + select: { + ordinal: true, + }, + }, + }, + orderBy: { submissionDate: 'desc' }, + }); + + const passwordChanges = await this.prisma.activityLog.findMany({ + where: { + userId: user.id, + module: 'auth', + action: ActivityLogAction.password_update, + }, + orderBy: { createdAt: 'desc' }, + }); + + return mapTo(UserAuditDto, { + loginAttempts: loginAttempts.map((la) => ({ + createdAt: la.createdAt, + action: la.action, + metadata: la.metadata, + })), + appSubmissions: appSubmissions.map((as) => ({ + submissionDate: as.submissionDate, + listingName: as.listings?.name, + listingId: as.listings?.id, + applicationId: as.id, + confirmationCode: as.confirmationCode, + ranking: as.applicationLotteryPositions?.[0]?.ordinal, + })), + passwordChanges: passwordChanges.map((pc) => ({ + createdAt: pc.createdAt, + action: pc.action, + })), + }); + } } diff --git a/api/src/utilities/build-user-where.ts b/api/src/utilities/build-user-where.ts index 595fda06607..e09bf5974f8 100644 --- a/api/src/utilities/build-user-where.ts +++ b/api/src/utilities/build-user-where.ts @@ -70,7 +70,7 @@ export const buildWhereClause = ( } params.filter.forEach((filter) => { - if (filter['isPortalUser']) { + if (String(filter['isPortalUser']) === 'true') { if (user?.userRoles?.isAdmin) { filters.push({ OR: [ @@ -131,78 +131,21 @@ export const buildWhereClause = ( }, }); } - } else if ('isPortalUser' in filter) { + } else if ('isPortalUser' in filter && String(filter['isPortalUser']) === 'false') { filters.push({ - AND: [ + OR: [ { - OR: [ - { - userRoles: { - isPartner: null, - }, - }, - { - userRoles: { - isPartner: false, - }, - }, - ], + userRoles: null, }, { - OR: [ - { - userRoles: { - isLimitedJurisdictionalAdmin: null, - }, - }, - { - userRoles: { - isLimitedJurisdictionalAdmin: false, - }, - }, - ], - }, - { - OR: [ - { - userRoles: { - isJurisdictionalAdmin: null, - }, - }, - { - userRoles: { - isJurisdictionalAdmin: false, - }, - }, - ], - }, - { - OR: [ - { - userRoles: { - isSupportAdmin: null, - }, - }, - { - userRoles: { - isSupportAdmin: false, - }, - }, - ], - }, - { - OR: [ - { - userRoles: { - isAdmin: null, - }, - }, - { - userRoles: { - isAdmin: false, - }, - }, - ], + userRoles: { + isAdmin: false, + isJurisdictionalAdmin: false, + isLimitedJurisdictionalAdmin: false, + isPartner: false, + isSupportAdmin: false, + isSuperAdmin: false, + }, }, ], }); diff --git a/api/test/unit/passports/single-use-code.strategy.spec.ts b/api/test/unit/passports/single-use-code.strategy.spec.ts index 5a0278e7075..668fdcd25ae 100644 --- a/api/test/unit/passports/single-use-code.strategy.spec.ts +++ b/api/test/unit/passports/single-use-code.strategy.spec.ts @@ -6,6 +6,7 @@ import { passwordToHash } from '../../../src/utilities/password-helpers'; import { SingleUseCodeStrategy } from '../../../src/passports/single-use-code.strategy'; import { LoginViaSingleUseCode } from '../../../src/dtos/auth/login-single-use-code.dto'; import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; +import { ActivityLogAction } from '../../../src/enums/shared/activity-log-action-enum'; describe('Testing single-use-code strategy', () => { let strategy: SingleUseCodeStrategy; @@ -20,6 +21,11 @@ describe('Testing single-use-code strategy', () => { strategy = module.get(SingleUseCodeStrategy); prisma = module.get(PrismaService); + prisma.activityLog.create = jest.fn().mockResolvedValue({}); + }); + + beforeEach(() => { + jest.clearAllMocks(); }); it('should fail because user does not exist', async () => { @@ -43,6 +49,17 @@ describe('Testing single-use-code strategy', () => { `user example@exygy.com attempted to log in, but does not exist`, ); + expect(prisma.activityLog.create).toHaveBeenCalledWith({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + metadata: { + email: 'example@exygy.com', + reason: 'user_not_found', + }, + }, + }); + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ include: { userRoles: true, @@ -365,6 +382,17 @@ describe('Testing single-use-code strategy', () => { async () => await strategy.validate(request as unknown as Request), ).rejects.toThrowError(`singleUseCodeUnauthorized`); + expect(prisma.activityLog.create).toHaveBeenCalledWith({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + userId: id, + metadata: { + reason: 'invalid_single_use_code', + }, + }, + }); + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ include: { userRoles: true, @@ -607,6 +635,17 @@ describe('Testing single-use-code strategy', () => { async () => await strategy.validate(request as unknown as Request), ).rejects.toThrowError(`User ${id} has not accepted the terms of service`); + expect(prisma.activityLog.create).toHaveBeenCalledWith({ + data: { + module: 'auth', + action: ActivityLogAction.login_failed, + userId: id, + metadata: { + reason: 'terms_of_service_not_accepted', + }, + }, + }); + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ include: { userRoles: true, @@ -667,6 +706,17 @@ describe('Testing single-use-code strategy', () => { await strategy.validate(request as unknown as Request); + expect(prisma.activityLog.create).toHaveBeenCalledWith({ + data: { + module: 'auth', + action: ActivityLogAction.login, + userId: id, + metadata: { + method: 'single_use_code', + }, + }, + }); + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ include: { userRoles: true, diff --git a/api/test/unit/services/activity-log-cleanup.service.spec.ts b/api/test/unit/services/activity-log-cleanup.service.spec.ts new file mode 100644 index 00000000000..a06f57ba98c --- /dev/null +++ b/api/test/unit/services/activity-log-cleanup.service.spec.ts @@ -0,0 +1,76 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../../src/services/prisma.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; +import { ActivityLogCleanupService } from '../../../src/services/activity-log-cleanup.service'; + +describe('ActivityLogCleanupService', () => { + let service: ActivityLogCleanupService; + let prisma: PrismaService; + let configService: ConfigService; + let cronJobService: CronJobService; + + const mockCronJobService = { + startCronJob: jest.fn(), + markCronJobAsStarted: jest.fn(), + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActivityLogCleanupService, + PrismaService, + { provide: CronJobService, useValue: mockCronJobService }, + ConfigService, + Logger, + ], + }).compile(); + + service = module.get(ActivityLogCleanupService); + prisma = module.get(PrismaService); + configService = module.get(ConfigService); + cronJobService = module.get(CronJobService); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should skip cleanup if retention days is not set or invalid', async () => { + configService.get = jest.fn().mockReturnValue(NaN); + prisma.activityLog.deleteMany = jest.fn(); + + const result = await service.cleanup(); + + expect(result).toEqual({ success: false }); + expect(prisma.activityLog.deleteMany).not.toHaveBeenCalled(); + }); + + it('should delete only auth module entries older than retention days', async () => { + configService.get = jest.fn().mockImplementation((key) => { + if (key === 'ACTIVITY_LOG_RETENTION_DAYS') return 30; + return null; + }); + + const mockDeleteResult = { count: 12 }; + prisma.activityLog.deleteMany = jest + .fn() + .mockResolvedValue(mockDeleteResult); + + const result = await service.cleanup(); + + expect(result).toEqual({ success: true }); + expect(cronJobService.markCronJobAsStarted).toHaveBeenCalledWith( + 'activity-log-cleanup', + ); + expect(prisma.activityLog.deleteMany).toHaveBeenCalledWith({ + where: { + module: 'auth', + createdAt: { + lt: expect.any(Date), + }, + }, + }); + }); +}); diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index d6da4096073..d5d32c10155 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1779,13 +1779,19 @@ export class ApplicationsService { /** * trigger the remove PII cron job */ - removePiiCronJob(options: IRequestOptions = {}): Promise { + removePiiCronJob( + params: { + /** requestBody */ + body?: PaginationDTO + } = {} as any, + options: IRequestOptions = {} + ): Promise { return new Promise((resolve, reject) => { let url = basePath + "/applications/removePIICronJob" const configs: IRequestConfig = getConfigs("put", "application/json", url, options) - let data = null + let data = params.body configs.data = data @@ -2256,6 +2262,27 @@ export class UserService { /** 适配ios13,get请求不允许带body */ + axios(configs, resolve, reject) + }) + } + /** + * Get user audit log + */ + getAuditLog( + params: { + /** */ + id: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/{id}/audit" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + /** 适配ios13,get请求不允许带body */ + axios(configs, resolve, reject) }) } @@ -7422,6 +7449,14 @@ export interface ApplicationCreate { preferredUnitTypes: IdDTO[] } +export interface PaginationDTO { + /** */ + page?: number + + /** */ + pageSize?: number +} + export interface ApplicationUpdate { /** */ id: string @@ -7831,6 +7866,42 @@ export interface UserUpdate { jurisdictions?: IdDTO[] } +export interface AuditLogEntry { + /** */ + createdAt: Date + + /** */ + action: ActivityLogAction + + /** */ + metadata?: object +} + +export interface AppSubmission { + /** */ + submissionDate: Date + + /** */ + listingName: string + + /** */ + confirmationCode: string + + /** */ + ranking?: number +} + +export interface UserAudit { + /** */ + loginAttempts: AuditLogEntry[] + + /** */ + appSubmissions: AppSubmission[] + + /** */ + passwordChanges: AuditLogEntry[] +} + export interface Login { /** */ email: string @@ -7981,14 +8052,6 @@ export interface CommunityTypeDTO { description?: string } -export interface PaginationDTO { - /** */ - page?: number - - /** */ - pageSize?: number -} - export interface FeatureFlagAssociate { /** */ id: string @@ -8488,6 +8551,12 @@ export enum ModificationEnum { "remove" = "remove", } +export enum ActivityLogAction { + "login" = "login", + "login_failed" = "login_failed", + "password_update" = "password_update", +} + export enum MfaType { "sms" = "sms", "email" = "email", diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json index 548f740dee4..294f4aab064 100644 --- a/sites/partners/page_content/locale_overrides/general.json +++ b/sites/partners/page_content/locale_overrides/general.json @@ -1,4 +1,5 @@ { + "account.security": "Security", "actions.copy": "Copy", "admin.addFeatureFlags": "Add all new feature flags", "admin.byFeatureFlag": "By feature flag", @@ -593,6 +594,7 @@ "nav.applications": "Applications", "nav.flags": "Flags", "nav.siteTitlePartners": "Partners Portal", + "nav.support": "Support", "nav.users": "Users", "neighborhoodAmenities.distance.onSite": "On site", "neighborhoodAmenities.distance.oneBlock": "One block", @@ -644,6 +646,14 @@ "settings.preferenceChangesRequiredEdit": "Changes required before editing", "settings.preferenceEditError": "This preference is already attached to a listing and needs to be removed before it can be edited.", "settings.properties": "Properties", + "support.appSubmissions": "Application Submissions", + "support.loginAttempts": "Login Attempts (Last 30 Days)", + "support.passwordHistory": "Password Change History", + "support.ranking": "Lottery Rank", + "support.totalApplications": "Total Applications", + "support.totalAttempts": "Total Attempts", + "support.totalChanges": "Total Changes", + "t.action": "Action", "t.add": "Add", "t.addItem": "Add item", "t.addItemsToEdit": "Add items to edit", @@ -658,12 +668,15 @@ "t.charactersOver": "You are %{count} characters over the limit", "t.copy": "Make a copy", "t.custom": "Custom", + "t.copySuccess": "Copied to clipboard", "t.date": "Date", "t.delete": "Delete", + "t.details": "Details", "t.descriptionTitle": "Description", "t.discard": "Discard", "t.done": "Done", "t.draft": "Draft", + "t.email": "Email", "t.emailingExportSuccess": "An email containing the exported file has been sent to %{email}", "t.end": "End", "t.endTime": "End time", @@ -676,7 +689,9 @@ "t.export": "Export", "t.exportSuccess": "File exported successfully", "t.exportToCSV": "Export to CSV", + "t.failed": "Failed", "t.featureFlag": "Feature flag", + "t.firstName": "First name", "t.fileName": "File name", "t.filter": "Filter", "t.formSubmitted": "Submitting form, wait", @@ -686,13 +701,16 @@ "t.jurisdictions": "Jurisdictions", "t.label": "Label", "t.language": "Language", + "t.lastName": "Last name", "t.link": "Link", "t.listing": "Listings", "t.listingSingle": "Listing", + "t.loading": "Loading...", "t.makePrimaryPhoto": "Make primary photo", "t.monthlyMinimumIncome": "Minimum monthly income", "t.new": "New", "t.notes": "Notes", + "t.notSpecified": "Not specified", "t.option": "Option", "t.optional": "Optional", "t.order": "Order", @@ -718,6 +736,9 @@ "t.selectLanguage": "Select language", "t.settings": "Settings", "t.startTime": "Start time", + "t.status": "Status", + "t.submissionDate": "Submission Date", + "t.success": "Success", "t.submitNew": "Submit & new", "t.title": "Title", "t.updated": "Updated", @@ -725,6 +746,7 @@ "t.url": "URL", "t.verified": "Verified", "t.view": "View", + "users.accountDetails": "Account details", "users.addPassword": "Add a password", "users.addUser": "Add user", "users.administrator": "Administrator", @@ -746,6 +768,8 @@ "users.requestResendDescription": "Your token expired. You will need to have a new confirmation link sent to you.", "users.requestResendExplanation": "Please enter your email, and we'll send you a new confirmation link", "users.resendInvite": "Resend invite", + "users.resetPassword": "Reset Password on behalf", + "users.resetPasswordDescription": "Send a password reset email to this user's email address.", "users.totalUsers": "total users", "users.unconfirmed": "Unconfirmed", "users.userDeleted": "User deleted", diff --git a/sites/partners/src/components/shared/SupportStatusBar.tsx b/sites/partners/src/components/shared/SupportStatusBar.tsx new file mode 100644 index 00000000000..1188c635e87 --- /dev/null +++ b/sites/partners/src/components/shared/SupportStatusBar.tsx @@ -0,0 +1,32 @@ +import React from "react" +import { Tag } from "@bloom-housing/ui-seeds" +import { t } from "@bloom-housing/ui-components" + +export interface SupportStatusBarProps { + className?: string + confirmedAt?: Date | string | null +} + +const SupportStatusBar = ({ className, confirmedAt }: SupportStatusBarProps) => { + const isConfirmed = !!confirmedAt + return ( +
+
+
+ + {isConfirmed + ? t("users.confirmed") || "Confirmed" + : t("users.unconfirmed") || "Unconfirmed"} + +
+
+
+ ) +} + +export { SupportStatusBar as default, SupportStatusBar } diff --git a/sites/partners/src/layouts/SupportLayout.tsx b/sites/partners/src/layouts/SupportLayout.tsx new file mode 100644 index 00000000000..2c84aee23d3 --- /dev/null +++ b/sites/partners/src/layouts/SupportLayout.tsx @@ -0,0 +1,105 @@ +import React, { useContext, useEffect, useMemo } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import { + t, + Breadcrumbs, + BreadcrumbLink, + PageHeader, + TabNav, + TabNavItem, + AppearanceSizeType, +} from "@bloom-housing/ui-components" +import { AuthContext } from "@bloom-housing/shared-helpers" +import Layout from "./index" +import { SupportStatusBar } from "../components/shared/SupportStatusBar" +import { getSupportTabs } from "../lib/helpers" +import { User } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import headerStyles from "../components/shared/NavigationHeader.module.scss" + +interface SupportLayoutProps { + children: React.ReactNode + user?: User + userId: string + breadcrumbLabel?: string + breadcrumbHref?: string +} + +const SupportLayout = ({ + children, + user, + userId, + breadcrumbLabel, + breadcrumbHref, +}: SupportLayoutProps) => { + const { profile } = useContext(AuthContext) + const router = useRouter() + + useEffect(() => { + if (profile && !profile.userRoles?.isAdmin) { + void router.push("/unauthorized") + } + }, [profile, router]) + + const tabNavItems = useMemo(() => { + return ( + + {getSupportTabs(userId, t).map((tab) => ( + + {tab.label} + + ))} + + ) + }, [router.asPath, userId]) + + if (profile && !profile.userRoles?.isAdmin) { + return null + } + + const userName = user ? `${user.firstName} ${user.lastName}` : "" + + return ( + + + {`${t("nav.support")} - ${t("nav.siteTitlePartners")}`} + + + {t("nav.support")} + {breadcrumbLabel ? ( + <> + + {userName || t("users.viewUser")} + + + {breadcrumbLabel} + + + ) : ( + + {userName || t("users.viewUser")} + + )} + + } + /> + +
+
{children}
+
+
+ ) +} + +export default SupportLayout diff --git a/sites/partners/src/layouts/index.tsx b/sites/partners/src/layouts/index.tsx index 4b913477317..b0b6bf94edf 100644 --- a/sites/partners/src/layouts/index.tsx +++ b/sites/partners/src/layouts/index.tsx @@ -19,6 +19,12 @@ const Layout = (props) => { href: "/", }) } + if (profile?.userRoles?.isAdmin) { + menuLinks.push({ + title: t("nav.support", { defaultValue: "Support" }), + href: "/support", + }) + } if (profile?.userRoles?.isAdmin || profile?.userRoles?.isJurisdictionalAdmin) { menuLinks.push({ title: t("nav.users"), diff --git a/sites/partners/src/lib/helpers.tsx b/sites/partners/src/lib/helpers.tsx index 8b07371dc7d..c0059889ca1 100644 --- a/sites/partners/src/lib/helpers.tsx +++ b/sites/partners/src/lib/helpers.tsx @@ -15,6 +15,18 @@ import { Application, IncomePeriodEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +export type NavigationHeaderTabsElement = { + label: string + path: string + activePaths: string[] + content?: React.ReactNode +} +export enum ActivityLogAction { + login = "login", + login_failed = "login_failed", + password_update = "password_update", +} + import * as styles from "../components/listings/PaperListingForm/ListingForm.module.scss" export enum YesNoAnswer { @@ -321,3 +333,26 @@ export const mergeApplicationNames = (applications: Application[]) => { return `${names.join(", ")}` } + +export const getSupportTabs = (userId: string, t: any): NavigationHeaderTabsElement[] => [ + { + label: t("users.userDetails") || "User Details", + path: `/support/${userId}`, + activePaths: [`/support/${userId}`], + }, + { + label: t("support.appSubmissions") || "Applications", + path: `/support/${userId}/applications`, + activePaths: [`/support/${userId}/applications`], + }, + { + label: t("support.loginAttempts") || "Login Attempts", + path: `/support/${userId}/login-attempts`, + activePaths: [`/support/${userId}/login-attempts`], + }, + { + label: t("support.passwordHistory") || "Password History", + path: `/support/${userId}/password-history`, + activePaths: [`/support/${userId}/password-history`], + }, +] diff --git a/sites/partners/src/lib/hooks.ts b/sites/partners/src/lib/hooks.ts index c40304a360a..f8b393728ad 100644 --- a/sites/partners/src/lib/hooks.ts +++ b/sites/partners/src/lib/hooks.ts @@ -14,6 +14,7 @@ import { MultiselectQuestionFilterParams, MultiselectQuestionsApplicationSectionEnum, OrderByEnum, + serviceOptions, UserRole, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" @@ -40,8 +41,9 @@ interface UseSingleFlaggedApplicationDataProps extends UseSingleApplicationDataP search?: string } -type UseUserListProps = PaginationProps & { +export type UseUserListProps = PaginationProps & { search?: string + filter?: any[] } type UseListingsDataProps = PaginationProps & { @@ -471,11 +473,11 @@ export function useReservedCommunityTypeList() { } } -export function useUserList({ page, limit, search = "" }: UseUserListProps) { +export function useUserList({ page, limit, search = "", filter }: UseUserListProps) { const params = { page, limit, - filter: [ + filter: filter || [ { isPortalUser: true, $comparison: EnumListingFilterParamsComparison["="], @@ -696,3 +698,18 @@ export function useWatchOnFormNumberFieldsChange( // eslint-disable-next-line react-hooks/exhaustive-deps }, [fieldToTriggerWatch.join(","), fieldValuesToWatch.join(","), trigger]) } + +export function useUserAudit(userId: string) { + const { userService } = useContext(AuthContext) + const endpoint = `/user/${userId}/audit` + + const fetcher = () => userService.getAuditLog({ id: userId }) + + const { data, error } = useSWR(userId ? endpoint : null, fetcher) + + return { + auditData: data, + auditLoading: !error && !data, + auditError: error, + } +} diff --git a/sites/partners/src/pages/support/[id].tsx b/sites/partners/src/pages/support/[id].tsx new file mode 100644 index 00000000000..606bc9a10de --- /dev/null +++ b/sites/partners/src/pages/support/[id].tsx @@ -0,0 +1,192 @@ +import React, { useContext, useState } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import dayjs from "dayjs" +import { t, AlertBox, Breadcrumbs, BreadcrumbLink } from "@bloom-housing/ui-components" +import { AuthContext, MessageContext } from "@bloom-housing/shared-helpers" +import { Button, Card, FieldValue, Grid, Heading, Icon } from "@bloom-housing/ui-seeds" +import DocumentDuplicateIcon from "@heroicons/react/24/solid/DocumentDuplicateIcon" +import SupportLayout from "../../layouts/SupportLayout" +import useSWR from "swr" +import { getSupportTabs } from "../../lib/helpers" + +const SupportUserDetail = () => { + const { profile, userService } = useContext(AuthContext) + const { addToast } = useContext(MessageContext) + const router = useRouter() + const [errorAlert, setErrorAlert] = useState(false) + const [submitLoading, setSubmitLoading] = useState(false) + const [cooldown, setCooldown] = useState(0) + + const userId = router.query.id as string + const COOLDOWN_KEY = `reset_cooldown_${userId}` + + // Fetch the user using standard SWR pattern + const fetcher = () => userService.retrieve({ id: userId }) + const { data: user, error } = useSWR(userId ? `/api/adapter/user/${userId}` : null, fetcher) + + // Initialize cooldown from localStorage on mount + React.useEffect(() => { + if (userId) { + const expiry = localStorage.getItem(COOLDOWN_KEY) + if (expiry) { + const remaining = Math.ceil((parseInt(expiry) - Date.now()) / 1000) + if (remaining > 0) setCooldown(remaining) + } + } + }, [userId, COOLDOWN_KEY]) + + // Countdown timer + React.useEffect(() => { + if (cooldown > 0) { + const timer = setTimeout(() => setCooldown((prev) => prev - 1), 1000) + return () => clearTimeout(timer) + } else { + localStorage.removeItem(COOLDOWN_KEY) + } + }, [cooldown, COOLDOWN_KEY]) + + if (error) return
{t("t.errorOccurred")}
+ + const handlePasswordReset = async () => { + setSubmitLoading(true) + setErrorAlert(false) + try { + const publicUrl = user?.jurisdictions?.[0]?.publicUrl || window.location.origin + await userService.forgotPassword({ + body: { + email: user.email, + appUrl: publicUrl, + }, + }) + + // Set 60 second cooldown + const duration = 60 + setCooldown(duration) + localStorage.setItem(COOLDOWN_KEY, (Date.now() + duration * 1000).toString()) + + addToast( + t("authentication.forgotPassword.message", { + defaultValue: "If an account exists for this email, a password reset link will be sent.", + }), + { variant: "success" } + ) + } catch (err) { + console.error(err) + setErrorAlert(true) + } finally { + setSubmitLoading(false) + } + } + + const copyToClipboard = (text: string) => { + void navigator.clipboard.writeText(text) + addToast(t("t.copySuccess", { defaultValue: "Copied to clipboard" }), { variant: "success" }) + } + + return ( + + {errorAlert && ( + setErrorAlert(false)} + closeable + type="alert" + inverted + > + {t("account.settings.alerts.genericError")} + + )} + + {!user ? ( +
{t("t.loading")}
+ ) : ( +
+
+ + + + {t("users.accountDetails")} + + + + + + + {user.firstName} + + + {user.lastName} + + + + + +
+ {user.email} + +
+
+
+ + + {user.language || t("t.notSpecified")} + + +
+ + + + {dayjs(user.createdAt).format("MMMM DD, YYYY")} + + + + + {user.confirmedAt ? t("users.confirmed") : t("users.unconfirmed")} + + + +
+
+
+
+ + +
+ )} +
+ ) +} + +export default SupportUserDetail diff --git a/sites/partners/src/pages/support/[id]/applications.tsx b/sites/partners/src/pages/support/[id]/applications.tsx new file mode 100644 index 00000000000..ce6f2da6741 --- /dev/null +++ b/sites/partners/src/pages/support/[id]/applications.tsx @@ -0,0 +1,121 @@ +import React, { useContext } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import dayjs from "dayjs" +import Link from "next/link" +import { t, Breadcrumbs, BreadcrumbLink, AgTable } from "@bloom-housing/ui-components" +import { AuthContext } from "@bloom-housing/shared-helpers" +import SupportLayout from "../../../layouts/SupportLayout" +import useSWR from "swr" +import { useUserAudit } from "../../../lib/hooks" + +const SupportUserApplications = () => { + const { userService } = useContext(AuthContext) + const router = useRouter() + const userId = router.query.id as string + + // Fetch the user using standard SWR pattern + const fetcher = () => userService.retrieve({ id: userId }) + const { data: user, error } = useSWR(userId ? `/api/adapter/user/${userId}` : null, fetcher) + + const { auditData, auditLoading } = useUserAudit(userId) + + if (error) return
{t("t.errorOccurred")}
+ + return ( + +
+
+ {!user ? ( +
{t("t.loading")}
+ ) : ( + + value ? dayjs(value).format("MM/DD/YYYY hh:mm A") : "n/a", + flex: 1, + }, + { + headerName: t("listings.listingName"), + field: "listingName", + flex: 2, + cellRendererFramework: (params) => { + const submission = params.data + if (!submission.listingId) return params.value + return ( + + {params.value} + + ) + }, + }, + { + headerName: t("application.details.number"), + field: "confirmationCode", + flex: 1, + cellRendererFramework: (params) => { + const submission = params.data + if (!submission.applicationId) return params.value + return ( + + {params.value} + + ) + }, + }, + { + headerName: t("support.ranking"), + field: "ranking", + valueFormatter: ({ value }) => value || "n/a", + flex: 1, + }, + ], + totalItemsLabel: t("support.totalApplications"), + }} + data={{ + items: auditData?.appSubmissions || [], + loading: auditLoading, + totalItems: auditData?.appSubmissions?.length || 0, + totalPages: 1, + }} + pagination={{ + perPage: 100, + setPerPage: () => { + /* no-op */ + }, + currentPage: 1, + setCurrentPage: () => { + /* no-op */ + }, + }} + search={{ + setSearch: () => { + /* no-op */ + }, + }} + /> + )} +
+
+
+ + ) +} + +export default SupportUserApplications diff --git a/sites/partners/src/pages/support/[id]/login-attempts.tsx b/sites/partners/src/pages/support/[id]/login-attempts.tsx new file mode 100644 index 00000000000..3429483d51c --- /dev/null +++ b/sites/partners/src/pages/support/[id]/login-attempts.tsx @@ -0,0 +1,98 @@ +import React, { useContext } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import dayjs from "dayjs" +import { t, Breadcrumbs, BreadcrumbLink, AgTable } from "@bloom-housing/ui-components" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { ActivityLogAction } from "../../../lib/helpers" +import SupportLayout from "../../../layouts/SupportLayout" +import useSWR from "swr" +import { useUserAudit } from "../../../lib/hooks" + +const SupportUserLoginAttempts = () => { + const { userService } = useContext(AuthContext) + const router = useRouter() + const userId = router.query.id as string + + // Fetch the user using standard SWR pattern + const fetcher = () => userService.retrieve({ id: userId }) + const { data: user, error } = useSWR(userId ? `/api/adapter/user/${userId}` : null, fetcher) + + const { auditData, auditLoading } = useUserAudit(userId) + + if (error) return
{t("t.errorOccurred")}
+ + return ( + +
+
+ {!user ? ( +
{t("t.loading")}
+ ) : ( + dayjs(value).format("MM/DD/YYYY hh:mm A"), + flex: 1, + }, + { + headerName: t("t.status"), + field: "action", + valueFormatter: ({ value }) => + value === ActivityLogAction.login ? t("t.success") : t("t.failed"), + flex: 1, + }, + { + headerName: t("t.details"), + field: "metadata", + valueGetter: ({ data }) => { + if (data.action === ActivityLogAction.login_failed) { + return data.metadata?.reason || "unknown" + } + return data.metadata?.method || "" + }, + flex: 2, + }, + ], + totalItemsLabel: t("support.totalAttempts"), + }} + data={{ + items: auditData?.loginAttempts || [], + loading: auditLoading, + totalItems: auditData?.loginAttempts?.length || 0, + totalPages: 1, + }} + pagination={{ + perPage: 100, + setPerPage: () => { + /* no-op */ + }, + currentPage: 1, + setCurrentPage: () => { + /* no-op */ + }, + }} + search={{ + setSearch: () => { + /* no-op */ + }, + }} + /> + )} +
+
+
+ + ) +} + +export default SupportUserLoginAttempts diff --git a/sites/partners/src/pages/support/[id]/password-history.tsx b/sites/partners/src/pages/support/[id]/password-history.tsx new file mode 100644 index 00000000000..9f1769da539 --- /dev/null +++ b/sites/partners/src/pages/support/[id]/password-history.tsx @@ -0,0 +1,85 @@ +import React, { useContext } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import dayjs from "dayjs" +import { t, Breadcrumbs, BreadcrumbLink, AgTable } from "@bloom-housing/ui-components" +import { AuthContext } from "@bloom-housing/shared-helpers" +import SupportLayout from "../../../layouts/SupportLayout" +import useSWR from "swr" +import { useUserAudit } from "../../../lib/hooks" + +const SupportUserPasswordHistory = () => { + const { userService } = useContext(AuthContext) + const router = useRouter() + const userId = router.query.id as string + + // Fetch the user using standard SWR pattern + const fetcher = () => userService.retrieve({ id: userId }) + const { data: user, error } = useSWR(userId ? `/api/adapter/user/${userId}` : null, fetcher) + + const { auditData, auditLoading } = useUserAudit(userId) + + if (error) return
{t("t.errorOccurred")}
+ + return ( + +
+
+ {!user ? ( +
{t("t.loading")}
+ ) : ( + dayjs(value).format("MM/DD/YYYY hh:mm A"), + flex: 1, + }, + { + headerName: t("t.action"), + field: "action", + valueFormatter: () => t("t.updated"), + flex: 1, + }, + ], + totalItemsLabel: t("support.totalChanges"), + }} + data={{ + items: auditData?.passwordChanges || [], + loading: auditLoading, + totalItems: auditData?.passwordChanges?.length || 0, + totalPages: 1, + }} + pagination={{ + perPage: 100, + setPerPage: () => { + /* no-op */ + }, + currentPage: 1, + setCurrentPage: () => { + /* no-op */ + }, + }} + search={{ + setSearch: () => { + /* no-op */ + }, + }} + /> + )} +
+
+
+ + ) +} + +export default SupportUserPasswordHistory diff --git a/sites/partners/src/pages/support/index.tsx b/sites/partners/src/pages/support/index.tsx new file mode 100644 index 00000000000..cd9d3a8f185 --- /dev/null +++ b/sites/partners/src/pages/support/index.tsx @@ -0,0 +1,133 @@ +import React, { useContext, useMemo, useState } from "react" +import Head from "next/head" +import dayjs from "dayjs" +import { AgTable, useAgTable, t, AlertBox } from "@bloom-housing/ui-components" +import { AuthContext } from "@bloom-housing/shared-helpers" +import Layout from "../../layouts" +import { useUserList } from "../../lib/hooks" +import { NavigationHeader } from "../../components/shared/NavigationHeader" +import { useRouter } from "next/router" + +const Support = () => { + const { profile } = useContext(AuthContext) + const router = useRouter() + const [errorAlert, setErrorAlert] = useState(false) + + const tableOptions = useAgTable() + + const columns = useMemo(() => { + return [ + { + headerName: t("t.name"), + field: "", + flex: 1, + minWidth: 150, + valueGetter: ({ data }) => { + const { firstName, lastName } = data + return `${firstName} ${lastName}` + }, + cellRendererFramework: (params) => { + const user = params.data + return ( + + ) + }, + }, + { + headerName: t("t.email"), + field: "email", + flex: 1, + minWidth: 250, + }, + { + headerName: t("listings.details.createdDate"), + field: "createdAt", + valueFormatter: ({ value }) => dayjs(value).format("MM/DD/YYYY"), + }, + { + headerName: t("listings.unit.status"), + field: "confirmedAt", + valueFormatter: ({ value }) => (value ? t("users.confirmed") : t("users.unconfirmed")), + }, + ] + }, [router]) + + const { + data: userList, + loading, + error, + } = useUserList({ + page: tableOptions.pagination.currentPage, + limit: tableOptions.pagination.itemsPerPage, + search: tableOptions.filter.filterValue, + filter: [{ isPortalUser: false }], + }) + + // Security check: Only admins can view this page + if (profile && !profile.userRoles?.isAdmin) { + void router.push("/unauthorized") + return null + } + + if (error) return
{t("t.errorOccurred")}
+ + return ( + + + {`${t("nav.support", { defaultValue: "Support" })} - ${t( + "nav.siteTitlePartners" + )}`} + + +
+
+ {errorAlert && ( + setErrorAlert(false)} + closeable + type="alert" + inverted + > + {t("account.settings.alerts.genericError")} + + )} + +
+
+
+ ) +} + +export default Support