diff --git a/express-api/package.json b/express-api/package.json index 244353ed1..c7ecb7f09 100644 --- a/express-api/package.json +++ b/express-api/package.json @@ -21,7 +21,7 @@ "author": "", "license": "ISC", "dependencies": { - "@bcgov/citz-imb-kc-css-api": "https://github.com/bcgov/citz-imb-kc-css-api/releases/download/v1.3.1/bcgov-citz-imb-kc-css-api-1.3.1.tgz", + "@bcgov/citz-imb-kc-css-api": "https://github.com/bcgov/citz-imb-kc-css-api/releases/download/v1.4.0/bcgov-citz-imb-kc-css-api-1.4.0.tgz", "@bcgov/citz-imb-kc-express": "https://github.com/bcgov/citz-imb-kc-express/releases/download/v1.0.5/bcgov-citz-imb-kc-express-1.0.5.tgz", "axios": "1.6.0", "body-parser": "1.20.2", diff --git a/express-api/src/appDataSource.ts b/express-api/src/appDataSource.ts index 8b97bf34f..2c47ddf25 100644 --- a/express-api/src/appDataSource.ts +++ b/express-api/src/appDataSource.ts @@ -2,6 +2,7 @@ import { DataSource } from 'typeorm'; import { CustomWinstonLogger } from '@/typeorm/utilities/CustomWinstonLogger'; import dotenv from 'dotenv'; import { resolve } from 'path'; +import Entities from '@/typeorm/entitiesIndex'; dotenv.config({ path: resolve(__dirname, '../../.env') }); @@ -25,7 +26,7 @@ export const AppDataSource = new DataSource({ migrationsRun: false, logging: true, logger: new CustomWinstonLogger(true), - entities: ['./src/typeorm/Entities/*.ts'], + entities: Entities, migrations: ['./src/typeorm/Migrations/Seeds/*.ts', './src/typeorm/Migrations/*.ts'], subscribers: [], }); diff --git a/express-api/src/controllers/admin/users/usersSchema.ts b/express-api/src/controllers/admin/users/usersSchema.ts index 0cdc85449..bd7e863f3 100644 --- a/express-api/src/controllers/admin/users/usersSchema.ts +++ b/express-api/src/controllers/admin/users/usersSchema.ts @@ -13,7 +13,7 @@ export const UserFilteringSchema = z.object({ role: z.string().optional(), position: z.string().optional(), id: z.string().uuid().optional(), - isDisabled: z.boolean().optional(), + guid: z.string().uuid().optional(), }); export type UserFiltering = z.infer & { id?: UUID }; //Kinda hacky, but the type expected in typeorm is more strict than what zod infers here. diff --git a/express-api/src/controllers/agencies/agenciesController.ts b/express-api/src/controllers/agencies/agenciesController.ts index 8ed5b5b0d..96efa723b 100644 --- a/express-api/src/controllers/agencies/agenciesController.ts +++ b/express-api/src/controllers/agencies/agenciesController.ts @@ -2,7 +2,6 @@ import { Request, Response } from 'express'; import * as agencyService from '@/services/agencies/agencyServices'; import { AgencyFilterSchema, AgencyPublicResponseSchema } from '@/services/agencies/agencySchema'; import { z } from 'zod'; -import KeycloakService from '@/services/keycloak/keycloakService'; import { KeycloakUser } from '@bcgov/citz-imb-kc-express'; import { Roles } from '@/constants/roles'; @@ -21,11 +20,10 @@ export const getAgencies = async (req: Request, res: Response) => { }] */ const kcUser = req.user as KeycloakUser; - const roles = await KeycloakService.getKeycloakUserRoles(kcUser.preferred_username); const filter = AgencyFilterSchema.safeParse(req.query); if (filter.success) { const agencies = await agencyService.getAgencies(filter.data); - if (!roles.map((role) => role.name).includes(Roles.ADMIN)) { + if (!kcUser.client_roles || !kcUser.client_roles.includes(Roles.ADMIN)) { const trimmed = AgencyPublicResponseSchema.array().parse(agencies); return res.status(200).send(trimmed); } diff --git a/express-api/src/controllers/users/usersController.ts b/express-api/src/controllers/users/usersController.ts index f6357c082..9780f545e 100644 --- a/express-api/src/controllers/users/usersController.ts +++ b/express-api/src/controllers/users/usersController.ts @@ -45,25 +45,25 @@ export const getUserInfo = async (req: Request, res: Response) => { * @param {Response} res Outgoing response. * @returns {Response} A 200 status with the most recent access request. */ -export const getUserAccessRequestLatest = async (req: Request, res: Response) => { - /** - * #swagger.tags = ['Users'] - * #swagger.description = 'Get user's most recent access request.' - * #swagger.security = [{ - "bearerAuth" : [] - }] - */ - const user = req?.user as KeycloakUser; - try { - const result = await userServices.getAccessRequest(user); - if (!result) { - return res.status(204).send('No access request was found.'); - } - return res.status(200).send(result); - } catch (e) { - return res.status(e?.code ?? 400).send(e?.message); - } -}; +// export const getUserAccessRequestLatest = async (req: Request, res: Response) => { +// /** +// * #swagger.tags = ['Users'] +// * #swagger.description = 'Get user's most recent access request.' +// * #swagger.security = [{ +// "bearerAuth" : [] +// }] +// */ +// const user = req?.user as KeycloakUser; +// try { +// const result = await userServices.getAccessRequest(user); +// if (!result) { +// return res.status(204).send('No access request was found.'); +// } +// return res.status(200).send(result); +// } catch (e) { +// return res.status(e?.code ?? 400).send(e?.message); +// } +// }; /** * @description Submits a user access request. @@ -79,9 +79,13 @@ export const submitUserAccessRequest = async (req: Request, res: Response) => { "bearerAuth" : [] }] */ - const user = req?.user as KeycloakUser; try { - const result = await userServices.addAccessRequest(req.body, user); + const result = await userServices.addKeycloakUserOnHold( + req.user as KeycloakUser, + Number(req.body.AgencyId), + req.body.Position, + req.body.Note, + ); return res.status(200).send(result); } catch (e) { return res.status(e?.code ?? 400).send(e?.message); @@ -94,26 +98,26 @@ export const submitUserAccessRequest = async (req: Request, res: Response) => { * @param {Response} res Outgoing response. * @returns {Response} A 200 status with the user access request matching the Id. */ -export const getUserAccessRequestById = async (req: Request, res: Response) => { - /** - * #swagger.tags = ['Users'] - * #swagger.description = 'Get a user access request by ID.' - * #swagger.security = [{ - "bearerAuth" : [] - }] - */ - const user = req?.user as KeycloakUser; - const requestId = Number(req.params?.reqeustId); - try { - const result = await userServices.getAccessRequestById(requestId, user); - if (!result) { - return res.status(404); - } - return res.status(200).send(result); - } catch (e) { - return res.status(e?.code ?? 400).send(e?.message); - } -}; +// export const getUserAccessRequestById = async (req: Request, res: Response) => { +// /** +// * #swagger.tags = ['Users'] +// * #swagger.description = 'Get a user access request by ID.' +// * #swagger.security = [{ +// "bearerAuth" : [] +// }] +// */ +// const user = req?.user as KeycloakUser; +// const requestId = Number(req.params?.reqeustId); +// try { +// const result = await userServices.getAccessRequestById(requestId, user); +// if (!result) { +// return res.status(404); +// } +// return res.status(200).send(result); +// } catch (e) { +// return res.status(e?.code ?? 400).send(e?.message); +// } +// }; /** * @description Updates a user access request. @@ -121,22 +125,22 @@ export const getUserAccessRequestById = async (req: Request, res: Response) => { * @param {Response} res Outgoing response. * @returns {Response} A 200 status with the updated request. */ -export const updateUserAccessRequest = async (req: Request, res: Response) => { - /** - * #swagger.tags = ['Users'] - * #swagger.description = 'Update a user access request.' - * #swagger.security = [{ - "bearerAuth" : [] - }] - */ - const user = req?.user as KeycloakUser; - try { - const result = await userServices.updateAccessRequest(req.body, user); - return res.status(200).send(result); - } catch (e) { - return res.status(e?.code ?? 400).send(e?.message); - } -}; +// export const updateUserAccessRequest = async (req: Request, res: Response) => { +// /** +// * #swagger.tags = ['Users'] +// * #swagger.description = 'Update a user access request.' +// * #swagger.security = [{ +// "bearerAuth" : [] +// }] +// */ +// const user = req?.user as KeycloakUser; +// try { +// const result = await userServices.updateAccessRequest(req.body, user); +// return res.status(200).send(result); +// } catch (e) { +// return res.status(e?.code ?? 400).send(e?.message); +// } +// }; /** * @description Gets user agency information. @@ -163,3 +167,17 @@ export const getUserAgencies = async (req: Request, res: Response) => { return res.status(e?.code ?? 400).send(e?.message); } }; + +export const getSelf = async (req: Request, res: Response) => { + try { + const user = userServices.normalizeKeycloakUser(req.user as KeycloakUser); + const result = await userServices.getUser(user.username); + if (result) { + return res.status(200).send(result); + } else { + return res.status(204).send(); //Valid request, but no user for this keycloak login. + } + } catch (e) { + return res.status(e?.code ?? 400).send(e?.message); + } +}; diff --git a/express-api/src/routes/usersRouter.ts b/express-api/src/routes/usersRouter.ts index daaf165bf..c30cb4b42 100644 --- a/express-api/src/routes/usersRouter.ts +++ b/express-api/src/routes/usersRouter.ts @@ -4,10 +4,11 @@ import express from 'express'; const router = express.Router(); router.route(`/info`).get(controllers.getUserInfo); -router.route(`/access/requests`).get(controllers.getUserAccessRequestLatest); +router.route(`/self`).get(controllers.getSelf); +// router.route(`/access/requests`).get(controllers.getUserAccessRequestLatest); router.route(`/access/requests`).post(controllers.submitUserAccessRequest); -router.route(`/access/requests/:requestId`).get(controllers.getUserAccessRequestById); -router.route(`/access/requests/:requestId`).put(controllers.updateUserAccessRequest); +// router.route(`/access/requests/:requestId`).get(controllers.getUserAccessRequestById); +// router.route(`/access/requests/:requestId`).put(controllers.updateUserAccessRequest); router.route(`/agencies/:username`).get(controllers.getUserAgencies); export default router; diff --git a/express-api/src/services/admin/usersServices.ts b/express-api/src/services/admin/usersServices.ts index f1fa2e402..281953166 100644 --- a/express-api/src/services/admin/usersServices.ts +++ b/express-api/src/services/admin/usersServices.ts @@ -17,13 +17,13 @@ const getUsers = async (filter: UserFiltering) => { DisplayName: filter.displayName, LastName: filter.lastName, Email: filter.email, + KeycloakUserId: filter.guid, Agency: { Name: filter.agency, }, Role: { Name: filter.role, }, - IsDisabled: filter.isDisabled, Position: filter.position, }, take: filter.quantity, diff --git a/express-api/src/services/keycloak/keycloakService.ts b/express-api/src/services/keycloak/keycloakService.ts index 5e3221d5a..bb4702710 100644 --- a/express-api/src/services/keycloak/keycloakService.ts +++ b/express-api/src/services/keycloak/keycloakService.ts @@ -25,7 +25,7 @@ import { randomUUID } from 'crypto'; import { AppDataSource } from '@/appDataSource'; import { DeepPartial, In, Not } from 'typeorm'; import userServices from '@/services/admin/usersServices'; -import { User } from '@/typeorm/Entities/User'; +import { User, UserStatus } from '@/typeorm/Entities/User'; import { Role } from '@/typeorm/Entities/Role'; /** @@ -202,7 +202,6 @@ const syncKeycloakUser = async (keycloakGuid: string) => { LastName: kuser.lastName, Email: kuser.email, Position: '', - IsDisabled: false, EmailVerified: false, IsSystem: false, Note: '', @@ -215,6 +214,8 @@ const syncKeycloakUser = async (keycloakGuid: string) => { RoleId: undefined, Agency: undefined, AgencyId: undefined, + Status: UserStatus.Active, + IsDisabled: false, }; return await userServices.addUser(newUser); } else { diff --git a/express-api/src/services/users/usersServices.ts b/express-api/src/services/users/usersServices.ts index 6d64ef0b7..e7a808b93 100644 --- a/express-api/src/services/users/usersServices.ts +++ b/express-api/src/services/users/usersServices.ts @@ -1,35 +1,28 @@ -import { User } from '@/typeorm/Entities/User'; +import { User, UserStatus } from '@/typeorm/Entities/User'; import { AppDataSource } from '@/appDataSource'; import { KeycloakBCeIDUser, KeycloakIdirUser, KeycloakUser } from '@bcgov/citz-imb-kc-express'; -import { z } from 'zod'; -import { AccessRequest } from '@/typeorm/Entities/AccessRequest'; import { In } from 'typeorm'; -import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode'; import { Agency } from '@/typeorm/Entities/Agency'; +import { randomUUID } from 'crypto'; interface NormalizedKeycloakUser { given_name: string; family_name: string; username: string; guid: string; + email: string; + display_name: string; } -const getUser = async (nameOrGuid: string): Promise => { - const userGuid = z.string().uuid().safeParse(nameOrGuid); - if (userGuid.success) { - return AppDataSource.getRepository(User).findOneBy({ - KeycloakUserId: userGuid.data, - }); - } else { - return AppDataSource.getRepository(User).findOneBy({ - Username: nameOrGuid, - }); - } +const getUser = async (username: string): Promise => { + const user = await AppDataSource.getRepository(User).findOneBy({ + Username: username, + }); + return user; }; const normalizeKeycloakUser = (kcUser: KeycloakUser): NormalizedKeycloakUser => { const provider = kcUser.identity_provider; - const username = kcUser.preferred_username; const normalizeUuid = (keycloakUuid: string) => keycloakUuid.toLowerCase().replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/g, '$1-$2-$3-$4-$5'); let user; @@ -39,7 +32,9 @@ const normalizeKeycloakUser = (kcUser: KeycloakUser): NormalizedKeycloakUser => return { given_name: user.given_name, family_name: user.family_name, - username: username, + username: kcUser.preferred_username, + email: kcUser.email, + display_name: kcUser.display_name, guid: normalizeUuid(user.idir_user_guid), }; case 'bceidbasic': @@ -47,7 +42,9 @@ const normalizeKeycloakUser = (kcUser: KeycloakUser): NormalizedKeycloakUser => return { given_name: '', family_name: '', - username: username, + username: kcUser.preferred_username, + email: kcUser.email, + display_name: kcUser.display_name, guid: normalizeUuid(user.bceid_user_guid), }; default: @@ -55,10 +52,10 @@ const normalizeKeycloakUser = (kcUser: KeycloakUser): NormalizedKeycloakUser => } }; -const getUserFromKeycloak = async (kcUser: KeycloakUser) => { - const normalized = normalizeKeycloakUser(kcUser); - return getUser(normalized.guid ?? normalized.username); -}; +// const getUserFromKeycloak = async (kcUser: KeycloakUser) => { +// const normalized = normalizeKeycloakUser(kcUser); +// return getUser(normalized.guid ?? normalized.username); +// }; const activateUser = async (kcUser: KeycloakUser) => { const normalizedUser = normalizeKeycloakUser(kcUser); @@ -78,74 +75,97 @@ const activateUser = async (kcUser: KeycloakUser) => { } }; -const getAccessRequest = async (kcUser: KeycloakUser) => { - const internalUser = await getUserFromKeycloak(kcUser); - const accessRequest = AppDataSource.getRepository(AccessRequest) - .createQueryBuilder('AccessRequests') - .leftJoinAndSelect('AccessRequests.AgencyId', 'Agencies') - .leftJoinAndSelect('AccessRequests.RoleId', 'Roles') - .leftJoinAndSelect('AccessRequests.UserId', 'Users') - .where('AccessRequests.UserId = :userId', { userId: internalUser.Id }) - .andWhere('AccessRequests.Status = :status', { status: 0 }) - .orderBy('AccessRequests.CreatedOn', 'DESC') - .getOne(); - return accessRequest; -}; - -const getAccessRequestById = async (requestId: number, kcUser: KeycloakUser) => { - const accessRequest = await AppDataSource.getRepository(AccessRequest) - .createQueryBuilder('AccessRequests') - .leftJoinAndSelect('AccessRequests.AgencyId', 'Agencies') - .leftJoinAndSelect('AccessRequests.RoleId', 'Roles') - .leftJoinAndSelect('AccessRequests.UserId', 'Users') - .where('AccessRequests.Id = :requestId', { requestId: requestId }) - .getOne(); - const internalUser = await getUserFromKeycloak(kcUser); - if (accessRequest && accessRequest.UserId != internalUser.Id) throw new Error('Not authorized.'); - return accessRequest; -}; - -const deleteAccessRequest = async (accessRequest: AccessRequest) => { - const existing = await AppDataSource.getRepository(AccessRequest).findOne({ - where: { Id: accessRequest.Id }, - }); - if (!existing) { - throw new ErrorWithCode('No access request found', 404); - } - const deletedRequest = AppDataSource.getRepository(AccessRequest).remove(accessRequest); - return deletedRequest; -}; - -const addAccessRequest = async (accessRequest: AccessRequest, kcUser: KeycloakUser) => { - if (accessRequest == null || accessRequest.AgencyId == null || accessRequest.RoleId == null) { +// const getAccessRequest = async (kcUser: KeycloakUser) => { +// const internalUser = await getUserFromKeycloak(kcUser); +// const accessRequest = AppDataSource.getRepository(AccessRequest) +// .createQueryBuilder('AccessRequests') +// .leftJoinAndSelect('AccessRequests.AgencyId', 'Agencies') +// .leftJoinAndSelect('AccessRequests.RoleId', 'Roles') +// .leftJoinAndSelect('AccessRequests.UserId', 'Users') +// .where('AccessRequests.UserId = :userId', { userId: internalUser.Id }) +// .andWhere('AccessRequests.Status = :status', { status: 0 }) +// .orderBy('AccessRequests.CreatedOn', 'DESC') +// .getOne(); +// return accessRequest; +// }; + +// const getAccessRequestById = async (requestId: number, kcUser: KeycloakUser) => { +// const accessRequest = await AppDataSource.getRepository(AccessRequest) +// .createQueryBuilder('AccessRequests') +// .leftJoinAndSelect('AccessRequests.AgencyId', 'Agencies') +// .leftJoinAndSelect('AccessRequests.RoleId', 'Roles') +// .leftJoinAndSelect('AccessRequests.UserId', 'Users') +// .where('AccessRequests.Id = :requestId', { requestId: requestId }) +// .getOne(); +// // eslint-disable-next-line @typescript-eslint/no-unused-vars +// const normalizedKc = await normalizeKeycloakUser(kcUser); +// return accessRequest; +// }; + +// const deleteAccessRequest = async (accessRequest: AccessRequest) => { +// const existing = await AppDataSource.getRepository(AccessRequest).findOne({ +// where: { Id: accessRequest.Id }, +// }); +// if (!existing) { +// throw new ErrorWithCode('No access request found', 404); +// } +// const deletedRequest = AppDataSource.getRepository(AccessRequest).remove(accessRequest); +// return deletedRequest; +// }; + +const addKeycloakUserOnHold = async ( + kcUser: KeycloakUser, + agencyId: number, + position: string, + note: string, +) => { + if ( + agencyId == null + // roleId == null + ) { throw new Error('Null argument.'); } - const internalUser = await getUserFromKeycloak(kcUser); - accessRequest.User = internalUser; - internalUser.Position = accessRequest.User.Position; - //Iterating through agencies and roles no longer necessary here? - - return AppDataSource.getRepository(AccessRequest).insert(accessRequest); + const normalizedKc = normalizeKeycloakUser(kcUser); + const systemUser = await AppDataSource.getRepository(User).findOne({ + where: { Username: 'system' }, + }); + const result = await AppDataSource.getRepository(User).insert({ + Id: randomUUID(), + FirstName: normalizedKc.given_name, + LastName: normalizedKc.family_name, + Email: normalizedKc.email, + DisplayName: normalizedKc.display_name, + KeycloakUserId: normalizedKc.guid, + Username: normalizedKc.username, + Status: UserStatus.OnHold, + IsSystem: false, + EmailVerified: false, + IsDisabled: false, + AgencyId: agencyId, + Position: position, + Note: note, + CreatedById: systemUser.Id, + }); + return result.generatedMaps[0]; }; -const updateAccessRequest = async (updateRequest: AccessRequest, kcUser: KeycloakUser) => { - if (updateRequest == null || updateRequest.AgencyId == null || updateRequest.RoleId == null) - throw new Error('Null argument.'); - - const internalUser = await getUserFromKeycloak(kcUser); +// const updateAccessRequest = async (updateRequest: AccessRequest, kcUser: KeycloakUser) => { +// if (updateRequest == null || updateRequest.AgencyId == null || updateRequest.RoleId == null) +// throw new Error('Null argument.'); - if (updateRequest.UserId != internalUser.Id) throw new Error('Not authorized.'); +// // eslint-disable-next-line @typescript-eslint/no-unused-vars +// const normalizedKc = await normalizeKeycloakUser(kcUser); - const result = await AppDataSource.getRepository(AccessRequest).update( - { Id: updateRequest.Id }, - updateRequest, - ); - if (!result.affected) { - throw new ErrorWithCode('Resource not found.', 404); - } - return result.generatedMaps[0]; -}; +// const result = await AppDataSource.getRepository(AccessRequest).update( +// { Id: updateRequest.Id }, +// updateRequest, +// ); +// if (!result.affected) { +// throw new ErrorWithCode('Resource not found.', 404); +// } +// return result.generatedMaps[0]; +// }; const getAgencies = async (username: string) => { const user = await getUser(username); @@ -183,11 +203,7 @@ const getAdministrators = async (agencyIds: string[]) => { const userServices = { getUser, activateUser, - getAccessRequest, - getAccessRequestById, - deleteAccessRequest, - addAccessRequest, - updateAccessRequest, + addKeycloakUserOnHold, getAgencies, getAdministrators, normalizeKeycloakUser, diff --git a/express-api/src/typeorm/Entities/User.ts b/express-api/src/typeorm/Entities/User.ts index 20765e9ea..bd9fade7c 100644 --- a/express-api/src/typeorm/Entities/User.ts +++ b/express-api/src/typeorm/Entities/User.ts @@ -4,12 +4,19 @@ import { Agency } from '@/typeorm/Entities/Agency'; import { BaseEntity } from '@/typeorm/Entities/abstractEntities/BaseEntity'; import { Role } from '@/typeorm/Entities/Role'; +export enum UserStatus { + Active = 'Active', + OnHold = 'OnHold', + Denied = 'Denied', + Disabled = 'Disabled', +} + @Entity() export class User extends BaseEntity { @PrimaryColumn({ type: 'uuid' }) Id: UUID; - @Column({ type: 'character varying', length: 25 }) + @Column({ type: 'character varying', length: 100 }) @Index({ unique: true }) Username: string; @@ -76,4 +83,7 @@ export class User extends BaseEntity { @ManyToOne(() => Role, (role) => role.Users, { nullable: true }) @JoinColumn({ name: 'RoleId' }) Role: Relation; + + @Column({ type: 'enum', enum: UserStatus }) + Status: UserStatus; } diff --git a/express-api/src/typeorm/Migrations/1707780132021-StatusEnumInUserTable.ts b/express-api/src/typeorm/Migrations/1707780132021-StatusEnumInUserTable.ts new file mode 100644 index 000000000..641838a24 --- /dev/null +++ b/express-api/src/typeorm/Migrations/1707780132021-StatusEnumInUserTable.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class StatusEnumInUserTable1707780132021 implements MigrationInterface { + name = 'StatusEnumInUserTable1707780132021'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."user_status_enum" AS ENUM('Active', 'OnHold', 'Denied', 'Disabled')`, + ); + await queryRunner.query(`ALTER TABLE "user" ADD "Status" "public"."user_status_enum"`); + await queryRunner.query(`UPDATE "user" SET "Status" = 'Active' WHERE "Username" = 'system'`); + await queryRunner.query(`UPDATE "user" SET "Status" = 'OnHold' WHERE "Status" IS NULL`); + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "Status" SET NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "Status"`); + await queryRunner.query(`DROP TYPE "public"."user_status_enum"`); + } +} diff --git a/express-api/src/typeorm/Migrations/1708023042674-ExpandUsername.ts b/express-api/src/typeorm/Migrations/1708023042674-ExpandUsername.ts new file mode 100644 index 000000000..982e74c48 --- /dev/null +++ b/express-api/src/typeorm/Migrations/1708023042674-ExpandUsername.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ExpandUsername1708023042674 implements MigrationInterface { + name = 'ExpandUsername1708023042674'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "Username" TYPE character varying(100)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "Username" TYPE character varying(25)`, + ); + } +} diff --git a/express-api/src/typeorm/entitiesIndex.ts b/express-api/src/typeorm/entitiesIndex.ts new file mode 100644 index 000000000..df255d61b --- /dev/null +++ b/express-api/src/typeorm/entitiesIndex.ts @@ -0,0 +1,85 @@ +import { AdministrativeArea } from '@/typeorm/Entities/AdministrativeArea'; +import { Agency } from '@/typeorm/Entities/Agency'; +import { Building } from '@/typeorm/Entities/Building'; +import { BuildingConstructionType } from '@/typeorm/Entities/BuildingConstructionType'; +import { BuildingEvaluation } from '@/typeorm/Entities/BuildingEvaluation'; +import { BuildingFiscal } from '@/typeorm/Entities/BuildingFiscal'; +import { BuildingOccupantType } from '@/typeorm/Entities/BuildingOccupantType'; +import { BuildingPredominateUse } from '@/typeorm/Entities/BuildingPredominateUse'; +import { EvaluationKey } from '@/typeorm/Entities/EvaluationKey'; +import { FiscalKey } from '@/typeorm/Entities/FiscalKey'; +import { NotificationQueue } from '@/typeorm/Entities/NotificationQueue'; +import { NotificationTemplate } from '@/typeorm/Entities/NotificationTemplate'; +import { Parcel } from '@/typeorm/Entities/Parcel'; +import { ParcelBuilding } from '@/typeorm/Entities/ParcelBuilding'; +import { ParcelEvaluation } from '@/typeorm/Entities/ParcelEvaluation'; +import { ParcelFiscal } from '@/typeorm/Entities/ParcelFiscal'; +import { Project } from '@/typeorm/Entities/Project'; +import { ProjectAgencyResponse } from '@/typeorm/Entities/ProjectAgencyResponse'; +import { ProjectNote } from '@/typeorm/Entities/ProjectNote'; +import { ProjectNumber } from '@/typeorm/Entities/ProjectNumber'; +import { ProjectProperty } from '@/typeorm/Entities/ProjectProperty'; +import { ProjectRisk } from '@/typeorm/Entities/ProjectRisk'; +import { ProjectReport } from '@/typeorm/Entities/ProjectReport'; +import { ProjectSnapshot } from '@/typeorm/Entities/ProjectSnapshot'; +import { ProjectStatus } from '@/typeorm/Entities/ProjectStatus'; +import { ProjectStatusHistory } from '@/typeorm/Entities/ProjectStatusHistory'; +import { ProjectStatusNotification } from '@/typeorm/Entities/ProjectStatusNotification'; +import { ProjectStatusTransition } from '@/typeorm/Entities/ProjectStatusTransition'; +import { ProjectTask } from '@/typeorm/Entities/ProjectTask'; +import { ProjectType } from '@/typeorm/Entities/ProjectType'; +import { PropertyClassification } from '@/typeorm/Entities/PropertyClassification'; +import { PropertyType } from '@/typeorm/Entities/PropertyType'; +import { Province } from '@/typeorm/Entities/Province'; +import { RegionalDistrict } from '@/typeorm/Entities/RegionalDistrict'; +import { ReportType } from '@/typeorm/Entities/ReportType'; +import { Role } from '@/typeorm/Entities/Role'; +import { Task } from '@/typeorm/Entities/Task'; +import { TierLevel } from '@/typeorm/Entities/TierLevel'; +import { User } from '@/typeorm/Entities/User'; +import { Workflow } from '@/typeorm/Entities/Workflow'; +import { WorkflowProjectStatus } from '@/typeorm/Entities/WorkflowProjectStatus'; + +export default [ + AdministrativeArea, + Agency, + Building, + BuildingConstructionType, + BuildingEvaluation, + BuildingFiscal, + BuildingOccupantType, + BuildingPredominateUse, + EvaluationKey, + FiscalKey, + NotificationQueue, + NotificationTemplate, + Parcel, + ParcelBuilding, + ParcelEvaluation, + ParcelFiscal, + Project, + ProjectAgencyResponse, + ProjectNote, + ProjectNumber, + ProjectProperty, + ProjectRisk, + ProjectReport, + ProjectSnapshot, + ProjectStatus, + ProjectStatusHistory, + ProjectStatusNotification, + ProjectStatusTransition, + ProjectTask, + ProjectType, + PropertyClassification, + PropertyType, + Province, + RegionalDistrict, + ReportType, + Role, + Task, + TierLevel, + User, + Workflow, + WorkflowProjectStatus, +]; diff --git a/express-api/tests/testUtils/factories.ts b/express-api/tests/testUtils/factories.ts index 0c69dec4b..4d97dd3f3 100644 --- a/express-api/tests/testUtils/factories.ts +++ b/express-api/tests/testUtils/factories.ts @@ -1,11 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { AccessRequest } from '@/typeorm/Entities/AccessRequest'; import { Agency } from '@/typeorm/Entities/Agency'; -import { User } from '@/typeorm/Entities/User'; +import { User, UserStatus } from '@/typeorm/Entities/User'; import { faker } from '@faker-js/faker'; import { UUID } from 'crypto'; import { Request, Response } from 'express'; import { Role as RolesEntity } from '@/typeorm/Entities/Role'; +import { KeycloakUser } from '@bcgov/citz-imb-kc-express'; export class MockRes { statusValue: any; @@ -83,6 +84,7 @@ export const produceUser = (): User => { CreatedById: undefined, CreatedBy: undefined, Id: id, + Status: UserStatus.Active, DisplayName: faker.company.name(), FirstName: faker.person.firstName(), MiddleName: faker.person.middleName(), @@ -90,7 +92,6 @@ export const produceUser = (): User => { Email: faker.internet.email(), Username: faker.internet.userName(), Position: 'Tester', - IsDisabled: false, EmailVerified: false, IsSystem: false, Note: '', @@ -103,6 +104,7 @@ export const produceUser = (): User => { RoleId: undefined, Agency: produceAgency(id), AgencyId: undefined, + IsDisabled: false, }; }; @@ -173,3 +175,18 @@ export const produceRole = (): RolesEntity => { Users: [], }; }; + +export const produceKeycloak = (): KeycloakUser => { + return { + name: faker.string.alphanumeric(), + preferred_username: faker.string.alphanumeric(), + email: faker.internet.email(), + display_name: faker.string.alphanumeric(), + client_roles: [faker.string.alphanumeric()], + identity_provider: 'idir', + idir_user_guid: faker.string.uuid(), + idir_username: faker.string.alphanumeric(), + given_name: faker.person.firstName(), + family_name: faker.person.lastName(), + }; +}; diff --git a/express-api/tests/unit/controllers/agencies/agencyController.test.ts b/express-api/tests/unit/controllers/agencies/agencyController.test.ts index 0292ed8bd..768268c46 100644 --- a/express-api/tests/unit/controllers/agencies/agencyController.test.ts +++ b/express-api/tests/unit/controllers/agencies/agencyController.test.ts @@ -52,6 +52,12 @@ describe('UNIT - Agencies Admin', () => { expect(mockResponse.statusValue).toBe(200); }); + it('should return status 200 and a list of agencies', async () => { + _getKeycloakUserRoles.mockImplementationOnce(() => []); + await controllers.getAgencies(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(200); + }); + it('should return status 200 and a list of agencies', async () => { mockRequest.query = { name: 'a', @@ -96,6 +102,11 @@ describe('UNIT - Agencies Admin', () => { expect(mockResponse.statusValue).toBe(200); expect(mockResponse.sendValue.Id).toBe(777); }); + it('should return status 404', async () => { + _getAgencyById.mockImplementationOnce(() => null); + await controllers.getAgencyById(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(404); + }); it('should return status 400', async () => { _getAgencyById.mockImplementationOnce(() => { throw new ErrorWithCode('', 400); diff --git a/express-api/tests/unit/controllers/users/usersController.test.ts b/express-api/tests/unit/controllers/users/usersController.test.ts index 8d83dd199..f7b9ab262 100644 --- a/express-api/tests/unit/controllers/users/usersController.test.ts +++ b/express-api/tests/unit/controllers/users/usersController.test.ts @@ -5,31 +5,47 @@ import { MockReq, MockRes, getRequestHandlerMocks, + produceKeycloak, produceRequest, + produceUser, } from '../../../testUtils/factories'; import { IKeycloakUser } from '@/services/keycloak/IKeycloakUser'; import { AccessRequest } from '@/typeorm/Entities/AccessRequest'; import { faker } from '@faker-js/faker'; -import { KeycloakUser } from '@bcgov/citz-imb-kc-express'; +import { KeycloakIdirUser, KeycloakUser } from '@bcgov/citz-imb-kc-express'; +import { ErrorWithCode } from '@/utilities/customErrors/ErrorWithCode'; const _activateUser = jest.fn(); const _getAccessRequest = jest.fn().mockImplementation(() => produceRequest()); const _getAccessRequestById = jest.fn().mockImplementation(() => produceRequest()); const _deleteAccessRequest = jest.fn().mockImplementation((req) => req); -const _addAccessRequest = jest.fn().mockImplementation((req) => req); +const _addKeycloakUserOnHold = jest + .fn() + .mockImplementation((kc: KeycloakUser, agencyId: string, position: string, note: string) => ({ + ...produceUser(), + AgencyId: agencyId, + Position: position, + Note: note, + })); const _updateAccessRequest = jest.fn().mockImplementation((req) => req); const _getAgencies = jest.fn().mockImplementation(() => ['1', '2', '3']); const _getAdministrators = jest.fn(); - +const _getUser = jest + .fn() + .mockImplementation((guid: string) => ({ ...produceUser(), KeycloakUserId: guid })); +const _normalizeKeycloakUser = jest.fn().mockImplementation(() => {}); jest.mock('@/services/users/usersServices', () => ({ activateUser: () => _activateUser(), getAccessRequest: () => _getAccessRequest(), getAccessRequestById: () => _getAccessRequestById(), deleteAccessRequest: (request: AccessRequest) => _deleteAccessRequest(request), - addAccessRequest: (request: AccessRequest, _kc: KeycloakUser) => _addAccessRequest(request), + addKeycloakUserOnHold: (kc: KeycloakUser, agencyId: string, position: string, note: string) => + _addKeycloakUserOnHold(kc, agencyId, position, note), updateAccessRequest: (request: AccessRequest, _kc: KeycloakUser) => _updateAccessRequest(request), getAgencies: () => _getAgencies(), getAdministrators: () => _getAdministrators(), + getUser: (guid: string) => _getUser(guid), + normalizeKeycloakUser: () => _normalizeKeycloakUser(), })); describe('UNIT - Testing controllers for users routes.', () => { @@ -69,81 +85,80 @@ describe('UNIT - Testing controllers for users routes.', () => { // }) }); - describe('getUserAccessRequestLatest', () => { - it('should return status 200 and an access request', async () => { - await controllers.getUserAccessRequestLatest(mockRequest, mockResponse); - expect(mockResponse.statusValue).toBe(200); - expect(mockResponse.sendValue.Id).toBeDefined(); - }); - - it('should return status 204 if no requests', async () => { - _getAccessRequest.mockImplementationOnce(() => null); - await controllers.getUserAccessRequestLatest(mockRequest, mockResponse); - expect(mockResponse.statusValue).toBe(204); - expect(mockResponse.sendValue.Id).toBeUndefined(); - }); - - it('should return status 400 if userService.getAccessRequest throws an error', async () => { - _getAccessRequest.mockImplementationOnce(() => { - throw new Error(); - }); - await controllers.getUserAccessRequestLatest(mockRequest, mockResponse); - expect(mockResponse.statusValue).toBe(400); - }); - }); - - describe('getUserAccessRequestById', () => { - const request = produceRequest(); - - it('should return status 200 and an access request', async () => { - _getAccessRequestById.mockImplementationOnce(() => request); - mockRequest.params.requestId = String(request.Id); - await controllers.getUserAccessRequestById(mockRequest, mockResponse); - expect(mockResponse.statusValue).toBe(200); - expect(mockResponse.sendValue.Id).toBe(request.Id); - }); - - it('should return status 404 if no request found', async () => { - _getAccessRequestById.mockImplementationOnce(() => null); - mockRequest.params.requestId = '-1'; - await controllers.getUserAccessRequestById(mockRequest, mockResponse); - expect(mockResponse.statusValue).toBe(404); - }); - }); - - describe('updateUserAccessRequest', () => { - it('should return status 200 and an access request', async () => { - const request = produceRequest(); - mockRequest.params.requestId = '1'; - mockRequest.body = request; - await controllers.updateUserAccessRequest(mockRequest, mockResponse); - expect(mockResponse.statusValue).toBe(200); - expect(mockResponse.sendValue.Id).toBe(request.Id); - }); - - it('should return status 400 if malformed', async () => { - mockRequest.params.requestId = '1'; - mockRequest.body = {}; - _updateAccessRequest.mockImplementationOnce(() => { - throw Error(); - }); - await controllers.updateUserAccessRequest(mockRequest, mockResponse); - expect(mockResponse.statusValue).toBe(400); - }); - }); + // describe('getUserAccessRequestLatest', () => { + // it('should return status 200 and an access request', async () => { + // await controllers.getUserAccessRequestLatest(mockRequest, mockResponse); + // expect(mockResponse.statusValue).toBe(200); + // expect(mockResponse.sendValue.Id).toBeDefined(); + // }); + + // it('should return status 204 if no requests', async () => { + // _getAccessRequest.mockImplementationOnce(() => null); + // await controllers.getUserAccessRequestLatest(mockRequest, mockResponse); + // expect(mockResponse.statusValue).toBe(204); + // expect(mockResponse.sendValue.Id).toBeUndefined(); + // }); + + // it('should return status 400 if userService.getAccessRequest throws an error', async () => { + // _getAccessRequest.mockImplementationOnce(() => { + // throw new Error(); + // }); + // await controllers.getUserAccessRequestLatest(mockRequest, mockResponse); + // expect(mockResponse.statusValue).toBe(400); + // }); + // }); + + // describe('getUserAccessRequestById', () => { + // const request = produceRequest(); + + // it('should return status 200 and an access request', async () => { + // _getAccessRequestById.mockImplementationOnce(() => request); + // mockRequest.params.requestId = String(request.Id); + // await controllers.getUserAccessRequestById(mockRequest, mockResponse); + // expect(mockResponse.statusValue).toBe(200); + // expect(mockResponse.sendValue.Id).toBe(request.Id); + // }); + + // it('should return status 404 if no request found', async () => { + // _getAccessRequestById.mockImplementationOnce(() => null); + // mockRequest.params.requestId = '-1'; + // await controllers.getUserAccessRequestById(mockRequest, mockResponse); + // expect(mockResponse.statusValue).toBe(404); + // }); + // }); + + // describe('updateUserAccessRequest', () => { + // it('should return status 200 and an access request', async () => { + // const request = produceRequest(); + // mockRequest.params.requestId = '1'; + // mockRequest.body = request; + // await controllers.updateUserAccessRequest(mockRequest, mockResponse); + // expect(mockResponse.statusValue).toBe(200); + // expect(mockResponse.sendValue.Id).toBe(request.Id); + // }); + + // it('should return status 400 if malformed', async () => { + // mockRequest.params.requestId = '1'; + // mockRequest.body = {}; + // _updateAccessRequest.mockImplementationOnce(() => { + // throw Error(); + // }); + // await controllers.updateUserAccessRequest(mockRequest, mockResponse); + // expect(mockResponse.statusValue).toBe(400); + // }); + // }); describe('submitUserAccessRequest', () => { it('should return status 201 and an access request', async () => { - const request = produceRequest(); - mockRequest.body = request; + mockRequest.user = produceKeycloak(); + mockRequest.body = { agencyId: 'bch' }; await controllers.submitUserAccessRequest(mockRequest, mockResponse); expect(mockResponse.statusValue).toBe(200); - expect(mockResponse.sendValue.Id).toBe(request.Id); }); it('should return status 400 if malformed', async () => { mockRequest.body = {}; - _addAccessRequest.mockImplementationOnce(() => { + _addKeycloakUserOnHold.mockImplementationOnce(() => { throw Error(); }); await controllers.submitUserAccessRequest(mockRequest, mockResponse); @@ -168,4 +183,47 @@ describe('UNIT - Testing controllers for users routes.', () => { expect(mockResponse.statusValue).toBe(400); }); }); + + describe('getSelf', () => { + it('should return the internal user corresponding to this keycloak', async () => { + const kcUser = produceKeycloak(); + _normalizeKeycloakUser.mockImplementationOnce(() => ({ + guid: (kcUser as KeycloakIdirUser).idir_user_guid, + })); + mockRequest.user = kcUser; + await controllers.getSelf(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(200); + }); + + it('should return no data', async () => { + const kcUser = produceKeycloak(); + _normalizeKeycloakUser.mockImplementationOnce(() => ({ + guid: (kcUser as KeycloakIdirUser).idir_user_guid, + })); + _getUser.mockImplementationOnce(() => null); + mockRequest.user = kcUser; + await controllers.getSelf(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(204); + }); + + it('should return 400', async () => { + const kcUser = produceKeycloak(); + _normalizeKeycloakUser.mockImplementationOnce(() => { + throw Error(); + }); + mockRequest.user = kcUser; + await controllers.getSelf(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(400); + }); + + it('should return 500', async () => { + const kcUser = produceKeycloak(); + _normalizeKeycloakUser.mockImplementationOnce(() => { + throw new ErrorWithCode('', 500); + }); + mockRequest.user = kcUser; + await controllers.getSelf(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(500); + }); + }); }); diff --git a/express-api/tests/unit/services/users/usersServices.test.ts b/express-api/tests/unit/services/users/usersServices.test.ts index 061b12b83..e46a7f63a 100644 --- a/express-api/tests/unit/services/users/usersServices.test.ts +++ b/express-api/tests/unit/services/users/usersServices.test.ts @@ -5,6 +5,7 @@ import { AccessRequest } from '@/typeorm/Entities/AccessRequest'; import { Agency } from '@/typeorm/Entities/Agency'; import { User } from '@/typeorm/Entities/User'; import { KeycloakUser } from '@bcgov/citz-imb-kc-express'; +import { faker } from '@faker-js/faker'; import { produceAgency, produceRequest, produceUser } from 'tests/testUtils/factories'; const _usersFindOneBy = jest @@ -54,6 +55,10 @@ jest .spyOn(AppDataSource.getRepository(User), 'find') .mockImplementation(async () => [produceUser()]); +jest + .spyOn(AppDataSource.getRepository(User), 'findOne') + .mockImplementation(async () => produceUser()); + jest .spyOn(AppDataSource.getRepository(User), 'findOneOrFail') .mockImplementation(async () => produceUser()); @@ -130,73 +135,66 @@ describe('UNIT - User services', () => { }); }); - describe('getAccessRequest', () => { - it('should get the latest accessRequest', async () => { - const request = await userServices.getAccessRequest(kcUser); - expect(AppDataSource.getRepository(AccessRequest).createQueryBuilder).toHaveBeenCalledTimes( - 1, - ); - }); - }); - - describe('getAccessRequestById', () => { - it('should get the accessRequest at the id specified', async () => { - const user = produceUser(); - const req = produceRequest(); - req.User = user; - req.UserId = user.Id; - _usersFindOneBy.mockResolvedValueOnce(user); - _requestQueryGetOne.mockImplementationOnce(() => req); - const request = await userServices.getAccessRequestById(req.Id, kcUser); - }); - }); - - describe('deleteAccessRequest', () => { - it('should return a deleted access request', async () => { - const req = await userServices.deleteAccessRequest(produceRequest()); - expect(_requestRemove).toHaveBeenCalledTimes(1); - }); - - it('should throw an error if the access request does not exist', () => { - jest - .spyOn(AppDataSource.getRepository(AccessRequest), 'findOne') - .mockImplementationOnce(() => undefined); - expect( - async () => await userServices.deleteAccessRequest(produceRequest()), - ).rejects.toThrow(); - }); - }); + // describe('getAccessRequest', () => { + // it('should get the latest accessRequest', async () => { + // const request = await userServices.getAccessRequest(kcUser); + // expect(AppDataSource.getRepository(AccessRequest).createQueryBuilder).toHaveBeenCalledTimes( + // 1, + // ); + // }); + // }); + + // describe('getAccessRequestById', () => { + // xit('should get the accessRequest at the id specified', async () => { + // const user = produceUser(); + // const req = produceRequest(); + // req.User = user; + // req.UserId = user.Id; + // _usersFindOneBy.mockResolvedValueOnce(user); + // _requestQueryGetOne.mockImplementationOnce(() => req); + // const request = await userServices.getAccessRequestById(req.Id, kcUser); + // }); + // }); + + // describe('deleteAccessRequest', () => { + // it('should return a deleted access request', async () => { + // const req = await userServices.deleteAccessRequest(produceRequest()); + // expect(_requestRemove).toHaveBeenCalledTimes(1); + // }); + + // it('should throw an error if the access request does not exist', () => { + // jest + // .spyOn(AppDataSource.getRepository(AccessRequest), 'findOne') + // .mockImplementationOnce(() => undefined); + // expect( + // async () => await userServices.deleteAccessRequest(produceRequest()), + // ).rejects.toThrow(); + // }); + // }); describe('addAccessRequest', () => { it('should add and return an access request', async () => { - const request = produceRequest(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request.AgencyId = {} as any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - request.RoleId = {} as any; - const req = await userServices.addAccessRequest(request, kcUser); - expect(_requestInsert).toHaveBeenCalledTimes(1); - }); - - it('should throw an error if the provided access request is null', () => { - expect(async () => await userServices.addAccessRequest(null, kcUser)).rejects.toThrow(); + const agencyId = faker.number.int(); + //const roleId = faker.string.uuid(); + const req = await userServices.addKeycloakUserOnHold(kcUser, agencyId, '', ''); + expect(_usersInsert).toHaveBeenCalledTimes(1); }); }); - describe('updateAccessRequest', () => { - it('should update and return the access request', async () => { - const req = produceRequest(); - _usersFindOneBy.mockResolvedValueOnce(req.User); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - req.RoleId = {} as any; - const request = await userServices.updateAccessRequest(req, kcUser); - expect(_requestUpdate).toHaveBeenCalledTimes(1); - }); - - it('should throw an error if the provided access request is null', () => { - expect(async () => await userServices.updateAccessRequest(null, kcUser)).rejects.toThrow(); - }); - }); + // describe('updateAccessRequest', () => { + // xit('should update and return the access request', async () => { + // const req = produceRequest(); + // //_usersFindOneBy.mockResolvedValueOnce(req.UserId); + // // eslint-disable-next-line @typescript-eslint/no-explicit-any + // req.RoleId = {} as any; + // const request = await userServices.updateAccessRequest(req, kcUser); + // expect(_requestUpdate).toHaveBeenCalledTimes(1); + // }); + + // it('should throw an error if the provided access request is null', () => { + // expect(async () => await userServices.updateAccessRequest(null, kcUser)).rejects.toThrow(); + // }); + // }); describe('getAgencies', () => { it('should return an array of agency ids', async () => { diff --git a/react-app/nginx.conf b/react-app/nginx.conf index 102c5aa98..7fc28d966 100644 --- a/react-app/nginx.conf +++ b/react-app/nginx.conf @@ -5,10 +5,13 @@ server { try_files $uri /index.html; } location /api/ { - proxy_pass http://pims-api-v2:5000/; + proxy_pass http://pims-api-v2:5000/api/; } location /api/api-docs/ { proxy_pass http://pims-api-v2:5000/api/api-docs/; } + location /api/auth/ { + proxy_pass http://pims-api-v2:5000/auth/; + } } diff --git a/react-app/src/App.tsx b/react-app/src/App.tsx index 64f0777bc..8fadbd200 100644 --- a/react-app/src/App.tsx +++ b/react-app/src/App.tsx @@ -9,14 +9,32 @@ import { ConfigContextProvider } from './contexts/configContext'; import AuthContextProvider from './contexts/authContext'; import AuthRouteGuard from './guards/AuthRouteGuard'; import BaseLayout from './components/layout/BaseLayout'; -import UsersTable from '@/pages/UsersTable'; +import { AccessRequest } from './pages/AccessRequest'; +import UsersManagement from './pages/UsersManagement'; const Router = () => { return ( - } /> + + + + } + /> + + + + + + } + /> { element={ - + } diff --git a/react-app/src/components/dialog/BaseDialog.tsx b/react-app/src/components/dialog/BaseDialog.tsx new file mode 100644 index 000000000..913eac8e6 --- /dev/null +++ b/react-app/src/components/dialog/BaseDialog.tsx @@ -0,0 +1,21 @@ +import { Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; +import React, { PropsWithChildren } from 'react'; + +export interface IBaseDialog extends PropsWithChildren { + open: boolean; + title: string; + actions: JSX.Element; +} + +const BaseDialog = (props: IBaseDialog) => { + const { open, title, children, actions } = props; + return ( + + {title} + {children} + {actions} + + ); +}; + +export default BaseDialog; diff --git a/react-app/src/components/dialog/ConfirmDialog.tsx b/react-app/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 000000000..6f076de6c --- /dev/null +++ b/react-app/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,37 @@ +import { ButtonProps } from '@mui/material'; +import BaseDialog from './BaseDialog'; +import DualActionButtons from './DualActionButtons'; +import React, { PropsWithChildren } from 'react'; + +interface IConfirmDialog extends PropsWithChildren { + title: string; + open: boolean; + onConfirm: () => Promise; + onCancel: () => Promise; + confirmButtonText?: string; + confirmButtonProps?: ButtonProps; +} + +const ConfirmDialog = (props: IConfirmDialog) => { + const { title, open, onConfirm, onCancel, confirmButtonProps, confirmButtonText, children } = + props; + return ( + + } + > + {children} + + ); +}; + +export default ConfirmDialog; diff --git a/react-app/src/components/dialog/DeleteDialog.tsx b/react-app/src/components/dialog/DeleteDialog.tsx new file mode 100644 index 000000000..e20b8ad52 --- /dev/null +++ b/react-app/src/components/dialog/DeleteDialog.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import { Box, TextField, Typography } from '@mui/material'; +import ConfirmDialog from './ConfirmDialog'; + +interface IDeleteDialog { + open: boolean; + title: string; + message: string; + deleteText?: string; + onDelete: () => Promise; + onClose: () => Promise; +} + +const DeleteDialog = (props: IDeleteDialog) => { + const { open, title, message, deleteText, onDelete, onClose } = props; + const [textFieldValue, setTextFieldValue] = useState(''); + return ( + + + {message} + To confirm deletion, please type Delete below. + { + setTextFieldValue(event.target.value); + }} + /> + + + ); +}; + +export default DeleteDialog; diff --git a/react-app/src/components/dialog/DualActionButtons.tsx b/react-app/src/components/dialog/DualActionButtons.tsx new file mode 100644 index 000000000..bddb140cd --- /dev/null +++ b/react-app/src/components/dialog/DualActionButtons.tsx @@ -0,0 +1,35 @@ +import { Box, Button, ButtonProps } from '@mui/material'; +import React from 'react'; + +interface IDualActionButtons { + onCancel: () => void; + onConfirm: () => void; + confirmText: string; + cancelText: string; + confirmButtonProps?: ButtonProps; +} + +const DualActionButtons = (props: IDualActionButtons) => { + const { onConfirm, onCancel, confirmText, cancelText, confirmButtonProps } = props; + return ( + + + + + ); +}; + +export default DualActionButtons; diff --git a/react-app/src/components/display/DataCard.tsx b/react-app/src/components/display/DataCard.tsx new file mode 100644 index 000000000..079692afa --- /dev/null +++ b/react-app/src/components/display/DataCard.tsx @@ -0,0 +1,68 @@ +import { columnNameFormatter, dateFormatter } from '@/utils/formatters'; +import { Box, Button, CardContent, CardHeader, Divider, Typography } from '@mui/material'; +import Card from '@mui/material/Card'; +import React from 'react'; + +interface IDataCard { + values: T; + title: string; + onEdit: () => void; + customFormatter?: (key: keyof T, value: any) => string | JSX.Element | undefined; +} + +const DataCard = (props: IDataCard) => { + const { values, title, customFormatter, onEdit } = props; + + const defaultFormatter = (key: keyof T, val: any) => { + const customFormat = customFormatter?.(key, val); + if (customFormat) { + return customFormat; + } + + if (val instanceof Date) { + return {dateFormatter(val)}; + } + + return {val}; + }; + + return ( + + onEdit()} + color={'primary'} + > + Edit + + } + /> + + {Object.keys(values).map((key, idx) => ( + + + + {columnNameFormatter(key)} + + {defaultFormatter(key as keyof T, values[key])} + + {idx < Object.keys(values).length - 1 && } + + ))} + + + ); +}; + +export default DataCard; diff --git a/react-app/src/components/form/AutocompleteField.test.tsx b/react-app/src/components/form/AutocompleteField.test.tsx new file mode 100644 index 000000000..a7f48cb9f --- /dev/null +++ b/react-app/src/components/form/AutocompleteField.test.tsx @@ -0,0 +1,33 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import '@testing-library/jest-dom'; +import AutocompleteFormField from './AutocompleteFormField'; + +jest.mock('react-hook-form', () => ({ + ...jest.requireActual('react-hook-form'), + Controller: () => <>, + useForm: () => ({ + control: () => ({}), + handleSubmit: () => jest.fn(), + }), + useFormContext: () => ({ + control: () => ({}), + }), +})); + +describe('', () => { + it('should render', () => { + render( + , + ); + }); +}); diff --git a/react-app/src/components/form/AutocompleteFormField.tsx b/react-app/src/components/form/AutocompleteFormField.tsx index 211b686e3..a9b3e9923 100644 --- a/react-app/src/components/form/AutocompleteFormField.tsx +++ b/react-app/src/components/form/AutocompleteFormField.tsx @@ -11,7 +11,7 @@ interface IAutocompleteProps { } const AutocompleteFormField = (props: IAutocompleteProps) => { - const { control } = useFormContext(); + const { control, getValues } = useFormContext(); const { name, options, label, sx } = props; return ( { render={({ field: { onChange } }) => ( } onChange={(_, data) => onChange(data.value)} + value={options.find((option) => option.value === getValues()[name]) ?? null} {...props} /> )} diff --git a/react-app/src/components/form/TextFormField.tsx b/react-app/src/components/form/TextFormField.tsx index d3d28a566..8b245a632 100644 --- a/react-app/src/components/form/TextFormField.tsx +++ b/react-app/src/components/form/TextFormField.tsx @@ -3,9 +3,16 @@ import { TextField, TextFieldProps } from '@mui/material'; import { useFormContext } from 'react-hook-form'; const TextFormField = (props: TextFieldProps) => { - const { register } = useFormContext(); + const { register, formState } = useFormContext(); const { name } = props; - return ; + return ( + + ); }; export default TextFormField; diff --git a/react-app/src/components/users/UserDetail.tsx b/react-app/src/components/users/UserDetail.tsx new file mode 100644 index 000000000..5cd4563ea --- /dev/null +++ b/react-app/src/components/users/UserDetail.tsx @@ -0,0 +1,243 @@ +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import DataCard from '../display/DataCard'; +import { Avatar, Box, Button, Grid, IconButton, Typography, useTheme } from '@mui/material'; +import Icon from '@mdi/react'; +import { mdiArrowLeft } from '@mdi/js'; +import { statusChipFormatter } from '@/utils/formatters'; +import DeleteDialog from '../dialog/DeleteDialog'; +import { deleteAccountConfirmText } from '@/constants/strings'; +import ConfirmDialog from '../dialog/ConfirmDialog'; +import { FormProvider, useForm } from 'react-hook-form'; +import TextInput from '@/components/form/TextFormField'; +import AutocompleteFormField from '@/components/form/AutocompleteFormField'; +import usePimsApi from '@/hooks/usePimsApi'; +import useDataLoader from '@/hooks/useDataLoader'; +import { User } from '@/hooks/api/useUsersApi'; +import { AuthContext } from '@/contexts/authContext'; +import { Agency } from '@/hooks/api/useAgencyApi'; + +interface IUserDetail { + userId: string; + onClose: () => void; +} + +const UserDetail = ({ userId, onClose }: IUserDetail) => { + const { pimsUser } = useContext(AuthContext); + const theme = useTheme(); + const api = usePimsApi(); + + const [openDeleteDialog, setOpenDeleteDialog] = useState(false); + const [openProfileDialog, setOpenProfileDialog] = useState(false); + const [openStatusDialog, setOpenStatusDialog] = useState(false); + + const { data, refreshData } = useDataLoader(() => api.users.getUserById(userId)); + const { data: agencyData, loadOnce: loadAgency } = useDataLoader(api.agencies.getAgencies); + loadAgency(); + + const agencyOptions = useMemo( + () => agencyData?.map((agency) => ({ label: agency.Name, value: agency.Id })) ?? [], + [agencyData], + ); + + const userStatusData = { + Status: data?.Status, + Role: 'Not implemented', + }; + + const userProfileData = { + DisplayName: data?.DisplayName, + Email: data?.Email, + FirstName: data?.FirstName, + LastName: data?.LastName, + Agency: data?.Agency, + Position: data?.Position, + CreatedOn: data?.CreatedOn ? new Date(data?.CreatedOn) : undefined, + LastLogin: data?.LastLogin ? new Date(data?.LastLogin) : undefined, + }; + + const customFormatterStatus = (key: keyof User, val: any) => { + if (key === 'Status') { + return statusChipFormatter(val); + } + }; + + const customFormatterProfile = (key: keyof User, val: any) => { + if (key === 'Agency' && val) { + return {(val as Agency).Name}; + } + }; + + const profileFormMethods = useForm({ + defaultValues: { + DisplayName: '', + Email: '', + FirstName: '', + LastName: '', + AgencyId: null, + Position: '', + }, + }); + + const statusFormMethods = useForm({ + defaultValues: { + Status: '', + Role: '', + }, + mode: 'onBlur', + }); + + useEffect(() => { + refreshData(); + }, [userId]); + + useEffect(() => { + profileFormMethods.reset({ + DisplayName: userProfileData.DisplayName, + Email: userProfileData.Email, + FirstName: userProfileData.FirstName, + LastName: userProfileData.LastName, + AgencyId: userProfileData.Agency?.Id, + Position: userProfileData.Position, + }); + statusFormMethods.reset({ + Status: userStatusData.Status, + Role: userStatusData.Role, + }); + }, [data]); + + return ( + + + onClose()}> + + + + + Back to User Overview + + + setOpenStatusDialog(true)} + /> + setOpenProfileDialog(true)} + /> + { + api.users.deleteUser(userId).then(() => { + setOpenDeleteDialog(false); + onClose(); + }); + }} + onClose={async () => setOpenDeleteDialog(false)} + /> + { + const isValid = await profileFormMethods.trigger(); + if (isValid) { + api.users + .updateUser(userId, { Id: userId, ...profileFormMethods.getValues() }) + .then(() => refreshData()); + setOpenProfileDialog(false); + } + }} + onCancel={async () => setOpenProfileDialog(false)} + > + + + + + + + + + + + + + + + + + + + + + + + + { + const isValid = await statusFormMethods.trigger(); + if (isValid) { + api.users + .updateUser(userId, { + Id: userId, + Status: statusFormMethods.getValues().Status, + }) + .then(() => refreshData()); + setOpenStatusDialog(false); + } + }} + onCancel={async () => setOpenStatusDialog(false)} + > + + + + + + + + + + + + + ); +}; + +export default UserDetail; diff --git a/react-app/src/pages/UsersTable.tsx b/react-app/src/components/users/UsersTable.tsx similarity index 88% rename from react-app/src/pages/UsersTable.tsx rename to react-app/src/components/users/UsersTable.tsx index fa909a4b4..15464e90b 100644 --- a/react-app/src/pages/UsersTable.tsx +++ b/react-app/src/components/users/UsersTable.tsx @@ -1,7 +1,6 @@ import { CustomDataGrid } from '@/components/table/DataTable'; import { Box, - Chip, Paper, SxProps, Typography, @@ -13,7 +12,12 @@ import { MenuItem, Tooltip, } from '@mui/material'; -import { GridColDef, gridFilteredSortedRowEntriesSelector, useGridApiRef } from '@mui/x-data-grid'; +import { + GridColDef, + GridEventListener, + gridFilteredSortedRowEntriesSelector, + useGridApiRef, +} from '@mui/x-data-grid'; import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; import { useKeycloak } from '@bcgov/citz-imb-kc-react'; import FilterAltOffIcon from '@mui/icons-material/FilterAltOff'; @@ -22,8 +26,7 @@ import { IUser } from '@/interfaces/IUser'; import AddIcon from '@mui/icons-material/Add'; import DownloadIcon from '@mui/icons-material/Download'; import { downloadExcelFile } from '@/utilities/downloadExcelFile'; -import useDataLoader from '@/hooks/useDataLoader'; -import usePimsApi from '@/hooks/usePimsApi'; +import { dateFormatter, statusChipFormatter } from '@/utils/formatters'; const CustomMenuItem = (props: PropsWithChildren & { value: string }) => { const theme = useTheme(); @@ -59,21 +62,25 @@ const CustomListSubheader = (props: PropsWithChildren) => { ); }; -const UsersTable = () => { +interface IUsersTable { + rowClickHandler: GridEventListener<'rowClick'>; + data: Record; + isLoading: boolean; + refreshData: () => void; + error: unknown; +} + +const UsersTable = (props: IUsersTable) => { // States and contexts + const { refreshData, data, error, isLoading, rowClickHandler } = props; const [users, setUsers] = useState([]); const [rowCount, setRowCount] = useState(0); const [keywordSearchContents, setKeywordSearchContents] = useState(''); const [selectValue, setSelectValue] = useState('All Users'); const [gridFilterItems, setGridFilterItems] = useState([]); const { state } = useKeycloak(); - const theme = useTheme(); const tableApiRef = useGridApiRef(); // Ref to MUI DataGrid - // Getting data from API - const usersApi = usePimsApi(); - const { data, refreshData, isLoading, error } = useDataLoader(usersApi.users.getAllUsers); - useEffect(() => { if (error) { console.error(error); @@ -85,13 +92,6 @@ const UsersTable = () => { } }, [state, data]); - // Determines colours of chips - const colorMap = { - Pending: 'info', - Active: 'success', - Hold: 'warning', - }; - // Sets quickfilter value of DataGrid. newValue is a string input. const updateSearchValue = useMemo(() => { return debounce((newValue) => { @@ -99,16 +99,6 @@ const UsersTable = () => { }, 100); }, [tableApiRef]); - // Converts dates to the MMM DD, YYYY locale - const dateFormatter = (params) => - params.value - ? new Intl.DateTimeFormat('en-US', { - month: 'short', - day: '2-digit', - year: 'numeric', - }).format(new Date(params.value)) - : undefined; - // Sets the preset filter based on the select input const selectPresetFilter = (value: string) => { // Clear the quick search contents @@ -166,16 +156,7 @@ const UsersTable = () => { headerName: 'Status', renderCell: (params) => { if (!params.value) return <>; - return ( - - ); + return statusChipFormatter(params.value); }, maxWidth: 100, }, @@ -186,7 +167,7 @@ const UsersTable = () => { flex: 1, }, { - field: 'Username', + field: 'DisplayName', headerName: 'IDIR/BCeID', minWidth: 150, flex: 1, @@ -196,6 +177,7 @@ const UsersTable = () => { headerName: 'Agency', minWidth: 125, flex: 1, + valueGetter: (params) => params.value?.Name ?? ``, }, { field: 'Position', @@ -213,14 +195,14 @@ const UsersTable = () => { field: 'CreatedOn', headerName: 'Created', minWidth: 120, - valueFormatter: dateFormatter, + valueFormatter: (params) => dateFormatter(params.value), type: 'date', }, { field: 'LastLogin', headerName: 'Last Login', minWidth: 120, - valueFormatter: dateFormatter, + valueFormatter: (params) => dateFormatter(params.value), type: 'date', }, ]; @@ -326,6 +308,7 @@ const UsersTable = () => { row.Id} columns={columns} rows={users} diff --git a/react-app/src/constants/strings.ts b/react-app/src/constants/strings.ts index 13f45b4d6..dc0a684a9 100644 --- a/react-app/src/constants/strings.ts +++ b/react-app/src/constants/strings.ts @@ -1,3 +1,5 @@ export const footerCopyright = `© ${new Date().getFullYear()} Government of British Columbia.`; export const landingPageTopText = `PIMS enables you to search properties owned by the Government of British Columbia`; export const landingPageBottomText = `The data provided can assist your agency in making informed, timely, and strategic decisions on the optimal use of real property assets on behalf of the people and priorities of British Columbia.`; +export const deleteAccountConfirmText = + 'Deleting a user account will not remove any property data created by the user. If the action is temporary, kindly consider changing the user status to Hold instead.'; diff --git a/react-app/src/contexts/authContext.tsx b/react-app/src/contexts/authContext.tsx index b8ab1e7e4..26d75c5f7 100644 --- a/react-app/src/contexts/authContext.tsx +++ b/react-app/src/contexts/authContext.tsx @@ -1,7 +1,9 @@ +import usePimsUser, { IPimsUser } from '@/hooks/usePimsUser'; import { AuthService, useKeycloak } from '@bcgov/citz-imb-kc-react'; import React, { createContext } from 'react'; export interface IAuthState { keycloak: AuthService; + pimsUser: IPimsUser; } export const AuthContext = createContext(undefined); @@ -13,15 +15,13 @@ export const AuthContext = createContext(undefined); */ export const AuthContextProvider: React.FC = (props) => { const keycloak = useKeycloak(); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const pimsUser = undefined; - // We could later add in a hook to give us user information from this context as well, thus allowing us to obtain - // both the keycloak authentication state and our internal user state in the same spot. + const pimsUser = usePimsUser(); return ( {props.children} diff --git a/react-app/src/guards/AuthRouteGuard.tsx b/react-app/src/guards/AuthRouteGuard.tsx index 18b97761e..7d3ae65a1 100644 --- a/react-app/src/guards/AuthRouteGuard.tsx +++ b/react-app/src/guards/AuthRouteGuard.tsx @@ -11,7 +11,7 @@ import React from 'react'; const AuthRouteGuard = (props: PropsWithChildren) => { const authStateContext = useContext(AuthContext); - if (!authStateContext.keycloak.isAuthenticated) { + if (!authStateContext.keycloak.isAuthenticated || authStateContext.pimsUser.isLoading) { return ; } diff --git a/react-app/src/hooks/api/useAgencyApi.ts b/react-app/src/hooks/api/useAgencyApi.ts new file mode 100644 index 000000000..63ebcc250 --- /dev/null +++ b/react-app/src/hooks/api/useAgencyApi.ts @@ -0,0 +1,22 @@ +import { IFetch } from '../useFetch'; + +export interface Agency { + Id: number; + Name: string; + Description: string | null; + Code: string; + SortOrder: number; +} + +const useAgencyApi = (absolueFetch: IFetch) => { + const getAgencies = async (): Promise => { + const { parsedBody } = await absolueFetch.get(`/agencies`); + return parsedBody as Agency[]; + }; + + return { + getAgencies, + }; +}; + +export default useAgencyApi; diff --git a/react-app/src/hooks/api/useUsersApi.ts b/react-app/src/hooks/api/useUsersApi.ts index 7348cf1bd..28e510e99 100644 --- a/react-app/src/hooks/api/useUsersApi.ts +++ b/react-app/src/hooks/api/useUsersApi.ts @@ -1,18 +1,67 @@ import { IFetch } from '../useFetch'; +import { Agency } from './useAgencyApi'; + +export interface User { + //temp interface, should standardize somehow + Id: string; + Username: string; + FirstName: string; + LastName: string; + KeycloakUserId: string; + Status: string; + Email: string; + LastLogin: Date; + CreatedOn: Date; + DisplayName: string; + AgencyId: number | null; + Agency: Agency | null; + Position: string; + Role: string; +} + +export interface AccessRequest { + Position?: string; + AgencyId: string; + Note?: string; +} const useUsersApi = (absoluteFetch: IFetch) => { const getLatestAccessRequest = async () => { const { parsedBody } = await absoluteFetch.get(`/users/access/requests`); return parsedBody; }; - + const getSelf = async (): Promise => { + const { parsedBody } = await absoluteFetch.get(`/users/self`); + return parsedBody as User; + }; + const submitAccessRequest = async (request: AccessRequest): Promise => { + const { parsedBody } = await absoluteFetch.post(`/users/access/requests`, request); + return parsedBody as User; + }; const getAllUsers = async () => { const { parsedBody } = await absoluteFetch.get('/admin/users'); return parsedBody; }; + const getUserById = async (userId: string): Promise => { + const { parsedBody } = await absoluteFetch.get(`/admin/users/${userId}`); + return parsedBody as User; + }; + const updateUser = async (userId: string, user: Partial) => { + const { parsedBody } = await absoluteFetch.put(`/admin/users/${userId}`, user); + return parsedBody; + }; + const deleteUser = async (userId: string) => { + const { parsedBody } = await absoluteFetch.del(`/admin/users/${userId}`, { Id: userId }); + return parsedBody; + }; return { getLatestAccessRequest, + getSelf, + submitAccessRequest, getAllUsers, + getUserById, + updateUser, + deleteUser, }; }; diff --git a/react-app/src/hooks/useDataLoader.ts b/react-app/src/hooks/useDataLoader.ts index 5bb637cbe..e1ae11b03 100644 --- a/react-app/src/hooks/useDataLoader.ts +++ b/react-app/src/hooks/useDataLoader.ts @@ -31,12 +31,17 @@ const useDataLoader = (); const [isLoading, setIsLoading] = useState(false); const [data, setData] = useState(); + const [oneTimeLoad, setOneTimeLoad] = useState(false); + const getData = useAsync(dataFetcher); + const refreshData = async (...args: AFArgs) => { setIsLoading(true); setError(undefined); + try { const response = await getData(...args); + if (!isMounted) { return; } @@ -54,7 +59,15 @@ const useDataLoader = { + if (oneTimeLoad) { + return; + } + setOneTimeLoad(true); + return refreshData(...args); + }; + + return { refreshData, isLoading, data, error, loadOnce }; }; export default useDataLoader; diff --git a/react-app/src/hooks/useFetch.ts b/react-app/src/hooks/useFetch.ts index b628c9075..127a074e7 100644 --- a/react-app/src/hooks/useFetch.ts +++ b/react-app/src/hooks/useFetch.ts @@ -30,6 +30,7 @@ const useFetch = (baseUrl?: string) => { ...params, headers: { Authorization: keycloak.getAuthorizationHeaderValue(), + 'Content-Type': 'application/json', }, }; diff --git a/react-app/src/hooks/usePimsApi.ts b/react-app/src/hooks/usePimsApi.ts index b8f8c0686..4c8d799eb 100644 --- a/react-app/src/hooks/usePimsApi.ts +++ b/react-app/src/hooks/usePimsApi.ts @@ -2,6 +2,7 @@ import { ConfigContext } from '@/contexts/configContext'; import { useContext } from 'react'; import useFetch from './useFetch'; import useUsersApi from './api/useUsersApi'; +import useAgencyApi from './api/useAgencyApi'; /** * usePimsApi - This stores all the sub-hooks we need to make calls to our API and helps manage authentication state for them. @@ -12,9 +13,11 @@ const usePimsApi = () => { const fetch = useFetch(config?.API_HOST); const users = useUsersApi(fetch); + const agencies = useAgencyApi(fetch); return { users, + agencies, }; }; diff --git a/react-app/src/hooks/usePimsUser.ts b/react-app/src/hooks/usePimsUser.ts new file mode 100644 index 000000000..458b81631 --- /dev/null +++ b/react-app/src/hooks/usePimsUser.ts @@ -0,0 +1,28 @@ +import { useKeycloak } from '@bcgov/citz-imb-kc-react'; +import usePimsApi from './usePimsApi'; +import useDataLoader from './useDataLoader'; +import { User } from './api/useUsersApi'; + +export interface IPimsUser { + data?: User; + refreshData: () => Promise; + isLoading: boolean; +} + +const usePimsUser = () => { + const keycloak = useKeycloak(); + const api = usePimsApi(); + const { data, refreshData, isLoading, loadOnce } = useDataLoader(api.users.getSelf, () => {}); + + if (!data && keycloak.isAuthenticated) { + loadOnce(); + } + + return { + data, + refreshData, + isLoading, + }; +}; + +export default usePimsUser; diff --git a/react-app/src/pages/AccessRequest.tsx b/react-app/src/pages/AccessRequest.tsx index 115552411..9951d4169 100644 --- a/react-app/src/pages/AccessRequest.tsx +++ b/react-app/src/pages/AccessRequest.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useContext, useMemo } from 'react'; import pendingImage from '@/assets/images/pending.svg'; import { Box, Button, Grid, Paper, Typography } from '@mui/material'; import TextInput from '@/components/form/TextFormField'; @@ -6,6 +6,11 @@ import AutocompleteFormField from '@/components/form/AutocompleteFormField'; import { useKeycloak } from '@bcgov/citz-imb-kc-react'; import { FormProvider, useForm } from 'react-hook-form'; import { accessPendingBlurb, signupTermsAndConditionsClaim } from '@/constants/jsxSnippets'; +import usePimsApi from '@/hooks/usePimsApi'; +import { AccessRequest as AccessRequestType } from '@/hooks/api/useUsersApi'; +import { AuthContext } from '@/contexts/authContext'; +import useDataLoader from '@/hooks/useDataLoader'; +import { Navigate } from 'react-router-dom'; const AccessPending = () => { return ( @@ -24,6 +29,15 @@ const AccessPending = () => { const RequestForm = ({ submitHandler }: { submitHandler: (d: any) => void }) => { const keycloak = useKeycloak(); + const api = usePimsApi(); + + const { loadOnce: agencyLoad, data: agencyData } = useDataLoader(api.agencies.getAgencies); + agencyLoad(); + + const agencyOptions = useMemo( + () => agencyData?.map((agency) => ({ label: agency.Name, value: agency.Id })) ?? [], + [agencyData], + ); const formMethods = useForm({ defaultValues: { @@ -37,12 +51,6 @@ const RequestForm = ({ submitHandler }: { submitHandler: (d: any) => void }) => }, }); - const placeholderData = [ - { label: 'BC Ministry of Education', value: 'key1' }, - { label: 'BC Ministry of Health', value: 'key2' }, - { label: 'BC Electric & Hydro', value: 'key3' }, - ]; - return ( <> @@ -85,21 +93,16 @@ const RequestForm = ({ submitHandler }: { submitHandler: (d: any) => void }) => - + @@ -118,22 +121,42 @@ const RequestForm = ({ submitHandler }: { submitHandler: (d: any) => void }) => }; export const AccessRequest = () => { - //Note: Placeholder state only, remove once API handling is implemented here. - const [requestSent, setRequestSent] = useState(false); + const api = usePimsApi(); + const auth = useContext(AuthContext); - const onSubmit = (data) => { - // eslint-disable-next-line no-console - console.log(JSON.stringify(data)); - setRequestSent(true); + const onSubmit = async (data: AccessRequestType) => { + try { + await api.users.submitAccessRequest(data); + await auth.pimsUser.refreshData(); + } catch (e) { + //Maybe we can display a little snackbar in these cases at some point. + // eslint-disable-next-line no-console + console.log(e?.message); + } }; + if (auth.pimsUser.data.Status && auth.pimsUser.data.Status === 'Active') { + return ; + } + return ( - + - {requestSent ? 'Access Request' : 'Access Pending'} + {auth.pimsUser.data ? 'Access Pending' : 'Access Request'} - {requestSent ? : } + {auth.pimsUser.data.Status && auth.pimsUser.data.Status === 'OnHold' ? ( + + ) : ( + + )} diff --git a/react-app/src/pages/DevZone.tsx b/react-app/src/pages/DevZone.tsx index d1065af74..52371065e 100644 --- a/react-app/src/pages/DevZone.tsx +++ b/react-app/src/pages/DevZone.tsx @@ -1,123 +1,113 @@ /* eslint-disable no-console */ //Simple component testing area. -import React, { useEffect, useState } from 'react'; -import { CustomDataGrid, DataGridFloatingMenu } from '@/components/table/DataTable'; -import { Box, Button, Chip, Paper, Typography } from '@mui/material'; -import { GridColDef } from '@mui/x-data-grid'; -import { mdiCheckCircle, mdiCloseThick } from '@mdi/js'; -import usePimsApi from '@/hooks/usePimsApi'; -import useDataLoader from '@/hooks/useDataLoader'; +import React from 'react'; +import BaseLayout from '@/components/layout/BaseLayout'; const Dev = () => { - const { users } = usePimsApi(); - const { - data: realData, - refreshData: refreshRealData, - isLoading: realDataLoading, - } = useDataLoader(users.getLatestAccessRequest, () => {}); + // const { users } = usePimsApi(); + // const { + // data: realData, + // refreshData: refreshRealData, + // isLoading: realDataLoading, + // } = useDataLoader(users.getLatestAccessRequest, () => {}); - const { - data: fakeData, - refreshData: refreshFakeData, - isLoading: fakeDataLoading, - } = useDataLoader( - async () => rows, - () => {}, - ); + // const { + // data: fakeData, + // refreshData: refreshFakeData, + // isLoading: fakeDataLoading, + // } = useDataLoader( + // async () => rows, + // () => {}, + // ); - const rows = [ - { UserId: 0, FirstName: 'Graham', LastName: 'Stewart', Status: 'Active', Date: '2023-04-02' }, - { UserId: 1, FirstName: 'John', LastName: 'Smith', Status: 'Pending', Date: '2023-04-02' }, - { UserId: 2, FirstName: 'Alice', LastName: 'Johnson', Status: 'Hold', Date: '2023-04-03' }, - { UserId: 3, FirstName: 'Bob', LastName: 'Anderson', Status: 'Active', Date: '2023-04-03' }, - { UserId: 4, FirstName: 'Emma', LastName: 'White', Status: 'Pending', Date: '2023-04-04' }, - { UserId: 5, FirstName: 'David', LastName: 'Taylor', Status: 'Hold', Date: '2023-04-04' }, - { UserId: 6, FirstName: 'Sophie', LastName: 'Brown', Status: 'Active', Date: '2023-04-05' }, - { UserId: 7, FirstName: 'Michael', LastName: 'Jones', Status: 'Pending', Date: '2023-04-05' }, - { UserId: 8, FirstName: 'Olivia', LastName: 'Wilson', Status: 'Active', Date: '2023-04-06' }, - { UserId: 9, FirstName: 'Daniel', LastName: 'Miller', Status: 'Hold', Date: '2023-04-06' }, - ]; + // const rows = [ + // { UserId: 0, FirstName: 'Graham', LastName: 'Stewart', Status: 'Active', Date: '2023-04-02' }, + // { UserId: 1, FirstName: 'John', LastName: 'Smith', Status: 'Pending', Date: '2023-04-02' }, + // { UserId: 2, FirstName: 'Alice', LastName: 'Johnson', Status: 'Hold', Date: '2023-04-03' }, + // { UserId: 3, FirstName: 'Bob', LastName: 'Anderson', Status: 'Active', Date: '2023-04-03' }, + // { UserId: 4, FirstName: 'Emma', LastName: 'White', Status: 'Pending', Date: '2023-04-04' }, + // { UserId: 5, FirstName: 'David', LastName: 'Taylor', Status: 'Hold', Date: '2023-04-04' }, + // { UserId: 6, FirstName: 'Sophie', LastName: 'Brown', Status: 'Active', Date: '2023-04-05' }, + // { UserId: 7, FirstName: 'Michael', LastName: 'Jones', Status: 'Pending', Date: '2023-04-05' }, + // { UserId: 8, FirstName: 'Olivia', LastName: 'Wilson', Status: 'Active', Date: '2023-04-06' }, + // { UserId: 9, FirstName: 'Daniel', LastName: 'Miller', Status: 'Hold', Date: '2023-04-06' }, + // ]; - const [dataRows, setDataRows] = useState([]); - useEffect(() => { - if (fakeData) { - setDataRows(fakeData); - } - }, [fakeData]); + // const [dataRows, setDataRows] = useState([]); + // useEffect(() => { + // if (fakeData) { + // setDataRows(fakeData); + // } + // }, [fakeData]); - useEffect(() => { - if (!realData) { - refreshRealData(); - } - }, [realData]); + // useEffect(() => { + // if (!realData) { + // refreshRealData(); + // } + // }, [realData]); - const colorMap = { - Pending: 'warning', - Active: 'success', - Hold: 'error', - }; + // const colorMap = { + // Pending: 'warning', + // Active: 'success', + // Hold: 'error', + // }; - const columns: GridColDef[] = [ - { - field: 'FirstName', - headerName: 'Given Name', - flex: 1, - }, - { - field: 'LastName', - headerName: 'Family Name', - flex: 1, - }, - { - field: 'Status', - headerName: 'Status', - flex: 1, - renderCell: (params) => { - return ; - }, - }, - { - field: 'Date', - headerName: 'Date', - flex: 1, - }, - { - field: 'actions', - headerName: 'Actions', - width: 100, - renderCell: (params) => ( - { - console.log(`Approve read this row: ${JSON.stringify(p.row)}`); - }, - }, - { - label: 'Deny', - iconPath: mdiCloseThick, - action: (p) => { - console.log(`Deny read this row: ${JSON.stringify(p.row)}`); - }, - }, - ]} - /> - ), - }, - ]; + // const columns: GridColDef[] = [ + // { + // field: 'FirstName', + // headerName: 'Given Name', + // flex: 1, + // }, + // { + // field: 'LastName', + // headerName: 'Family Name', + // flex: 1, + // }, + // { + // field: 'Status', + // headerName: 'Status', + // flex: 1, + // renderCell: (params) => { + // return ; + // }, + // }, + // { + // field: 'Date', + // headerName: 'Date', + // flex: 1, + // }, + // { + // field: 'actions', + // headerName: 'Actions', + // width: 100, + // renderCell: (params) => ( + // { + // console.log(`Approve read this row: ${JSON.stringify(p.row)}`); + // }, + // }, + // { + // label: 'Deny', + // iconPath: mdiCloseThick, + // action: (p) => { + // console.log(`Deny read this row: ${JSON.stringify(p.row)}`); + // }, + // }, + // ]} + // /> + // ), + // }, + // ]; return ( - - - - {realDataLoading ? ( - Real API Data loading.... - ) : ( - {JSON.stringify(realData, null, 2)} - )} + + {/* + @@ -128,8 +118,8 @@ const Dev = () => { rows={dataRows} loading={fakeDataLoading} /> - - + */} + ); }; diff --git a/react-app/src/pages/Home.tsx b/react-app/src/pages/Home.tsx index a62ba5dc5..df42cb8cd 100644 --- a/react-app/src/pages/Home.tsx +++ b/react-app/src/pages/Home.tsx @@ -1,10 +1,9 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { Box, Typography } from '@mui/material'; -import BaseLayout from '@/components/layout/BaseLayout'; import propertyVector from '@/assets/images/PIMS_logo.svg'; import { landingPageBottomText, landingPageTopText } from '@/constants/strings'; -import { AccessRequest } from './AccessRequest'; -import { useKeycloak } from '@bcgov/citz-imb-kc-react'; +import { AuthContext } from '@/contexts/authContext'; +import { Navigate } from 'react-router-dom'; const Landing = () => { return ( @@ -27,20 +26,22 @@ const Landing = () => { }; export const Home = () => { - const keycloak = useKeycloak(); + const auth = useContext(AuthContext); return ( - - - {keycloak.isAuthenticated ? : } - - + + {!auth.keycloak.isAuthenticated || auth.pimsUser.data?.Status === 'Active' ? ( + + ) : ( + + )} + ); }; diff --git a/react-app/src/pages/UsersManagement.tsx b/react-app/src/pages/UsersManagement.tsx new file mode 100644 index 000000000..d4007598f --- /dev/null +++ b/react-app/src/pages/UsersManagement.tsx @@ -0,0 +1,31 @@ +import UserDetail from '@/components/users/UserDetail'; +import UsersTable from '@/components/users/UsersTable'; +import useDataLoader from '@/hooks/useDataLoader'; +import usePimsApi from '@/hooks/usePimsApi'; +import React, { useState } from 'react'; + +const UsersManagement = () => { + const [selectedUserId, setSelectedUserId] = useState(''); + // Getting data from API + const usersApi = usePimsApi(); + const { data, refreshData, isLoading, error } = useDataLoader(usersApi.users.getAllUsers); + return selectedUserId ? ( + { + setSelectedUserId(''); + refreshData(); + }} + /> + ) : ( + setSelectedUserId(params.row.Id)} + data={data} + refreshData={refreshData} + isLoading={isLoading} + error={error} + /> + ); +}; + +export default UsersManagement; diff --git a/react-app/src/themes/appTheme.ts b/react-app/src/themes/appTheme.ts index f214806e8..564a2bc0f 100644 --- a/react-app/src/themes/appTheme.ts +++ b/react-app/src/themes/appTheme.ts @@ -104,9 +104,6 @@ const appTheme = createTheme({ boxShadow: 'none', }, }, - textPrimary: { - color: '#000', - }, }, }, MuiSelect: { diff --git a/react-app/src/utils/formatters.tsx b/react-app/src/utils/formatters.tsx new file mode 100644 index 000000000..4be800a83 --- /dev/null +++ b/react-app/src/utils/formatters.tsx @@ -0,0 +1,39 @@ +import { Chip, useTheme } from '@mui/material'; +import React from 'react'; + +export const dateFormatter = (input: any) => { + return input + ? new Intl.DateTimeFormat('en-US', { + month: 'short', + day: '2-digit', + year: 'numeric', + }).format(new Date(input)) + : ''; +}; + +export const columnNameFormatter = (input: string) => { + return input.replace(/([a-z])([A-Z])/g, '$1 $2'); +}; + +type ChipStatus = 'OnHold' | 'Active' | 'Disabled' | 'Denied'; //Replace with a better type eventually. +export const statusChipFormatter = (value: ChipStatus) => { + const theme = useTheme(); + const colorMap = { + Disabled: 'warning', + Active: 'success', + OnHold: 'info', + Denied: 'warning', + }; + return ( + <> + + + ); +};