From 79d73d5da9aad756061ded88c24ea5837616a0a3 Mon Sep 17 00:00:00 2001 From: Sanjay Babu Date: Tue, 23 Jul 2024 15:53:14 -0700 Subject: [PATCH] user mgmt supervisor backend implementation --- app/src/controllers/user.ts | 37 +++++ .../20240717000000_007_access-request.ts | 57 +++++++ app/src/db/models/access_request.ts | 32 ++++ app/src/db/models/index.ts | 1 + app/src/db/models/user.ts | 11 ++ app/src/db/prisma/schema.prisma | 20 +++ app/src/routes/v1/user.ts | 18 ++- app/src/services/user.ts | 91 ++++++++++- app/src/types/AccessRequest.ts | 10 ++ app/src/types/User.ts | 3 + app/src/types/index.ts | 1 + app/src/utils/enums/application.ts | 5 + .../src/components/user/UserCreateModal.vue | 97 +++++++++--- .../src/components/user/UserManageModal.vue | 14 +- frontend/src/components/user/UserTable.vue | 30 ++-- frontend/src/services/userService.ts | 34 ++++- frontend/src/types/AccessRequest.ts | 11 ++ frontend/src/types/User.ts | 3 + frontend/src/types/index.ts | 1 + frontend/src/utils/constants/application.ts | 6 +- frontend/src/utils/enums/application.ts | 18 +++ .../src/views/user/UserManagementView.vue | 142 ++++++++++++++---- 22 files changed, 562 insertions(+), 80 deletions(-) create mode 100644 app/src/db/migrations/20240717000000_007_access-request.ts create mode 100644 app/src/db/models/access_request.ts create mode 100644 app/src/types/AccessRequest.ts create mode 100644 frontend/src/types/AccessRequest.ts diff --git a/app/src/controllers/user.ts b/app/src/controllers/user.ts index 458652bed..112f3cdc4 100644 --- a/app/src/controllers/user.ts +++ b/app/src/controllers/user.ts @@ -4,6 +4,34 @@ import { addDashesToUuid, mixedQueryToArray, isTruthy } from '../utils/utils'; import type { NextFunction, Request, Response } from 'express'; const controller = { + // Request to create user & access + createUserAccessRequest: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await userService.createUserAccessRequest(req.body); + res.status(201).json(response); + } catch (e: unknown) { + next(e); + } + }, + + getNavsAndAccessRequests: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await userService.getNavsAndAccessRequests(); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + }, + // Request to revoke user access + revokeUserAccessRequest: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await userService.revokeUserAccessRequest(req.body); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } + }, + searchUsers: async (req: Request, res: Response, next: NextFunction) => { try { const userIds = mixedQueryToArray(req.query.userId as string); @@ -23,6 +51,15 @@ const controller = { } catch (e: unknown) { next(e); } + }, + + updateUserRole: async (req: Request, res: Response, next: NextFunction) => { + try { + const response = await userService.updateUserRole(req.body); + res.status(200).json(response); + } catch (e: unknown) { + next(e); + } } }; diff --git a/app/src/db/migrations/20240717000000_007_access-request.ts b/app/src/db/migrations/20240717000000_007_access-request.ts new file mode 100644 index 000000000..73621afa9 --- /dev/null +++ b/app/src/db/migrations/20240717000000_007_access-request.ts @@ -0,0 +1,57 @@ +import stamps from '../stamps'; + +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return ( + Promise.resolve() + // Create the access_request table + .then(() => + knex.schema.createTable('access_request', (table) => { + table.uuid('access_request_id').primary(); + table + .uuid('user_id') + .notNullable() + .references('user_id') + .inTable('user') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + // TODO: make a reference to role table + table.text('role'); + table + .enu('status', ['Approved', 'Pending', 'Rejected'], { + useNative: true, + enumName: 'access_request_status_enum' + }) + .defaultTo('Pending') + .notNullable(); + table.boolean('grant').notNullable(); + stamps(knex, table); + table.unique(['user_id']); + }) + ) + + .then(() => + knex.schema.raw(`create trigger before_update_access_request_trigger + before update on public.access_request + for each row execute procedure public.set_updated_at();`) + ) + + .then(() => + knex.schema.raw(`CREATE TRIGGER audit_access_request_trigger + AFTER UPDATE OR DELETE ON access_request + FOR EACH ROW EXECUTE PROCEDURE audit.if_modified_func();`) + ) + ); +} + +export async function down(knex: Knex): Promise { + return ( + Promise.resolve() + // Drop triggers + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS before_update_access_request_trigger ON access_request')) + .then(() => knex.schema.raw('DROP TRIGGER IF EXISTS audit_access_request_trigger ON access_request')) + // Drop the access_request table + .then(() => knex.schema.dropTableIfExists('access_request')) + ); +} diff --git a/app/src/db/models/access_request.ts b/app/src/db/models/access_request.ts new file mode 100644 index 000000000..71cf317a8 --- /dev/null +++ b/app/src/db/models/access_request.ts @@ -0,0 +1,32 @@ +import { Prisma } from '@prisma/client'; + +import type { Stamps } from '../stamps'; +import type { AccessRequest } from '../../types/AccessRequest'; // Import the access_request_status_enum type +import { UserStatus } from '../../utils/enums/application'; + +// Define types +const _accessRequest = Prisma.validator()({}); + +type PrismaRelationAccessRequest = Omit, keyof Stamps>; + +export default { + toPrismaModel(input: AccessRequest): PrismaRelationAccessRequest { + return { + access_request_id: input.accessRequestId, + grant: input.grant, + role: input.role, + status: input.status as UserStatus, // Cast the status property to UserStatus enum + user_id: input.userId + }; + }, + + fromPrismaModel(input: PrismaRelationAccessRequest): AccessRequest { + return { + accessRequestId: input.access_request_id, + grant: input.grant, + role: input.role, + userId: input.user_id as string, + status: input.status as UserStatus // Cast the status property to UserStatus enum + }; + } +}; diff --git a/app/src/db/models/index.ts b/app/src/db/models/index.ts index e62d513cf..2d29975ad 100644 --- a/app/src/db/models/index.ts +++ b/app/src/db/models/index.ts @@ -1,4 +1,5 @@ export { default as activity } from './activity'; +export { default as access_request } from './access_request'; export { default as document } from './document'; export { default as enquiry } from './enquiry'; export { default as identity_provider } from './identity_provider'; diff --git a/app/src/db/models/user.ts b/app/src/db/models/user.ts index 1a7371fe5..20ba60922 100644 --- a/app/src/db/models/user.ts +++ b/app/src/db/models/user.ts @@ -1,12 +1,16 @@ import { Prisma } from '@prisma/client'; +import access_request from './access_request'; + import type { Stamps } from '../stamps'; import type { User } from '../../types/User'; // Define types const _user = Prisma.validator()({}); +const _userWithAccessRequestGraph = Prisma.validator()({ include: { access_request: true } }); type PrismaRelationUser = Omit, keyof Stamps>; +type PrismaGraphUserAccessRequest = Prisma.userGetPayload; export default { toPrismaModel(input: User): PrismaRelationUser { @@ -35,5 +39,12 @@ export default { lastName: input.last_name, active: input.active }; + }, + fromPrismaModelWithAccessRequest(input: PrismaGraphUserAccessRequest): User { + const user = this.fromPrismaModel(input); + if (input.access_request) { + user.accessRequest = access_request.fromPrismaModel(input.access_request); + } + return user; } }; diff --git a/app/src/db/prisma/schema.prisma b/app/src/db/prisma/schema.prisma index 93fa188cf..de75c1d24 100644 --- a/app/src/db/prisma/schema.prisma +++ b/app/src/db/prisma/schema.prisma @@ -209,6 +209,7 @@ model user { created_at DateTime? @default(now()) @db.Timestamptz(6) updated_by String? updated_at DateTime? @db.Timestamptz(6) + access_request access_request? enquiry enquiry[] submission submission[] identity_provider identity_provider? @relation(fields: [idp], references: [idp], onDelete: Cascade, map: "user_idp_foreign") @@ -245,3 +246,22 @@ model enquiry { activity activity @relation(fields: [activity_id], references: [activity_id], onDelete: Cascade, map: "enquiry_activity_id_foreign") user user? @relation(fields: [assigned_user_id], references: [user_id], onDelete: Cascade, map: "enquiry_assigned_user_id_foreign") } + +model access_request { + access_request_id String @id @db.Uuid + user_id String @unique(map: "access_request_user_id_unique") @db.Uuid + role String? + status access_request_status_enum @default(Pending) + grant Boolean + created_by String? @default("00000000-0000-0000-0000-000000000000") + created_at DateTime? @default(now()) @db.Timestamptz(6) + updated_by String? + updated_at DateTime? @db.Timestamptz(6) + user user @relation(fields: [user_id], references: [user_id], onDelete: Cascade, map: "access_request_user_id_foreign") +} + +enum access_request_status_enum { + Approved + Pending + Rejected +} diff --git a/app/src/routes/v1/user.ts b/app/src/routes/v1/user.ts index b09b9c170..9031add80 100644 --- a/app/src/routes/v1/user.ts +++ b/app/src/routes/v1/user.ts @@ -8,9 +8,25 @@ import type { NextFunction, Request, Response } from 'express'; const router = express.Router(); router.use(requireSomeAuth); -// Submission endpoint +// Search for users router.get('/', userValidator.searchUsers, (req: Request, res: Response, next: NextFunction): void => { userController.searchUsers(req, res, next); }); +// Request to revoke a user +router.put('/request/access', (req: Request, res: Response, next: NextFunction): void => { + userController.revokeUserAccessRequest(req, res, next); +}); +// Request to create a user and access request +router.post('/request/access', (req: Request, res: Response, next: NextFunction): void => { + userController.createUserAccessRequest(req, res, next); +}); +// Update user role +router.put('/role', (req: Request, res: Response, next: NextFunction): void => { + userController.updateUserRole(req, res, next); +}); + +router.get('/navsRequests', (req: Request, res: Response, next: NextFunction): void => { + userController.getNavsAndAccessRequests(req, res, next); +}); export default router; diff --git a/app/src/services/user.ts b/app/src/services/user.ts index f69db4cbb..aab7b075b 100644 --- a/app/src/services/user.ts +++ b/app/src/services/user.ts @@ -3,10 +3,11 @@ import { Prisma } from '@prisma/client'; import { v4 as uuidv4, NIL } from 'uuid'; import prisma from '../db/dataConnection'; -import { identity_provider, user } from '../db/models'; +import { access_request, identity_provider, user } from '../db/models'; +import { UserStatus } from '../utils/enums/application'; import { parseIdentityKeyClaims } from '../utils/utils'; -import type { User, UserSearchParameters } from '../types'; +import type { AccessRequest, User, UserSearchParameters } from '../types'; const trxWrapper = (etrx: Prisma.TransactionClient | undefined = undefined) => (etrx ? etrx : prisma); @@ -117,6 +118,46 @@ const service = { return response; }, + /** + * @function createUserAccessRequest + * Create a user DB record if it does not exist and create an access_request record + * @param {object} data Incoming user data + * @returns {Promise} The result of running the insert operation + * @throws The error encountered upon db transaction failure + */ + createUserAccessRequest: async (data: User) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let response: any; + const oldUser = await prisma.user.findFirst({ + where: { + identity_id: data.identityId, + idp: data.idp + } + }); + + if (!oldUser) { + await prisma.$transaction(async (trx) => { + response = await service.createUser(data, trx); + + const newAccessRequest = { + accessRequestId: uuidv4(), + userId: response?.user_id, + grant: data.accessRequest?.grant as boolean, + role: data.accessRequest?.role as string, + status: UserStatus.PENDING + }; + const accessreqeustresponse = await trx.access_request.create({ + data: access_request.toPrismaModel(newAccessRequest) + }); + + response.access_request = accessreqeustresponse; + }); + return user.fromPrismaModelWithAccessRequest(response); + } else { + return user.fromPrismaModel(oldUser); + } + }, + /** * @function getCurrentUserId * Gets userId (primary identifier of a user in db) of currentUser. @@ -136,6 +177,24 @@ const service = { return user && user.user_id ? user.user_id : defaultValue; }, + /** + * @function listNavigators + * Lists all the + * @param {boolean} [active] Optional boolean on user active status + * @returns {Promise} The result of running the find operation + */ + getNavsAndAccessRequests: async () => { + const response = await prisma.user.findMany({ + include: { + access_request: true + }, + where: { + active: true + } + }); + return response.map((x) => user.fromPrismaModelWithAccessRequest(x)); + }, + /** * @function listIdps * Lists all known identity providers @@ -324,6 +383,34 @@ const service = { // Nothing to update return oldUser; } + }, + /** + * @function revokeUserAccessRequest + * Updates user access + * @returns {Promise} The result of running the put operation + */ + revokeUserAccessRequest: async (data: AccessRequest) => { + const newAccessRequest = { + accessRequestId: uuidv4(), + userId: data.userId, + grant: data.grant, + role: null, + status: UserStatus.PENDING + }; + const response = await prisma.access_request.create({ + data: access_request.toPrismaModel(newAccessRequest) + }); + + return access_request.fromPrismaModel(response); + }, + /** + * @function updateUserRole + * Updates user role + * @returns {Promise} The result of running the put operation + */ + updateUserRole: async (user: User) => { + console.log(user); + // TODO: Implement updateUserRole } }; diff --git a/app/src/types/AccessRequest.ts b/app/src/types/AccessRequest.ts new file mode 100644 index 000000000..7619fc0e4 --- /dev/null +++ b/app/src/types/AccessRequest.ts @@ -0,0 +1,10 @@ +import { IStamps } from '../interfaces/IStamps'; +import { UserStatus } from '../utils/enums/application'; + +export type AccessRequest = { + accessRequestId: string; // Primary key + grant: boolean; + role: string | null; + status: UserStatus; + userId: string; +} & Partial; diff --git a/app/src/types/User.ts b/app/src/types/User.ts index c9b3f7a4e..432af9a5a 100644 --- a/app/src/types/User.ts +++ b/app/src/types/User.ts @@ -1,5 +1,7 @@ import { IStamps } from '../interfaces/IStamps'; +import type { AccessRequest } from './AccessRequest'; + export type User = { userId?: string; // Primary Key identityId: string; @@ -10,4 +12,5 @@ export type User = { fullName: string | null; lastName: string | null; active: boolean; + accessRequest?: AccessRequest | null; } & Partial; diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 5690d27fe..185341e7c 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -1,4 +1,5 @@ export type { Activity } from './Activity'; +export type { AccessRequest } from './AccessRequest'; export type { BringForward } from './BringForward'; export type { ChefsFormConfig, ChefsFormConfigData } from './ChefsFormConfig'; export type { ChefsSubmissionExport } from './ChefsSubmissionExport'; diff --git a/app/src/utils/enums/application.ts b/app/src/utils/enums/application.ts index 4db04a522..caf701f58 100644 --- a/app/src/utils/enums/application.ts +++ b/app/src/utils/enums/application.ts @@ -30,6 +30,11 @@ export enum Initiative { HOUSING = 'HOUSING' } +export enum UserStatus { + APPROVED = 'Approved', + PENDING = 'Pending', + REJECTED = 'Rejected' +} export enum Regex { /** * Generic email regex modified to require domain of at least 2 characters diff --git a/frontend/src/components/user/UserCreateModal.vue b/frontend/src/components/user/UserCreateModal.vue index 1959dd2d5..dfbc320b0 100644 --- a/frontend/src/components/user/UserCreateModal.vue +++ b/frontend/src/components/user/UserCreateModal.vue @@ -1,10 +1,13 @@