Skip to content

Commit d3fe694

Browse files
Merge pull request #602 from IABTechLab/ans-UID2-5008-show-user-permissions-and-audits
Show user permissions and user audit trail
2 parents e34d3d5 + 6c9e72f commit d3fe694

21 files changed

+296
-19
lines changed

src/api/controllers/userController.ts

+9
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,15 @@ export class UserController {
146146
]);
147147
return;
148148
}
149+
if (
150+
userRoleData.userRoleId === UserRoleId.UID2Support ||
151+
userRoleData.userRoleId === UserRoleId.SuperUser
152+
) {
153+
res
154+
.status(403)
155+
.send([{ message: 'Unauthorized. You do not have permission to update to this role.' }]);
156+
return;
157+
}
149158
await this.userService.updateUser(req);
150159
res.sendStatus(200);
151160
}

src/api/middleware/usersMiddleware.ts

-8
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,6 @@ export const enrichUserWithSupportRoles = async (user: User) => {
6363
};
6464
};
6565

66-
export const enrichUserWithSuperUser = async (user: User) => {
67-
const userIsSuperUser = await isSuperUser(user.email);
68-
return {
69-
...user,
70-
isSuperUser: userIsSuperUser,
71-
};
72-
};
73-
7466
const userIdSchema = z.object({
7567
userId: z.coerce.number(),
7668
});

src/api/routers/managementRouter.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,27 @@ import express, { Response } from 'express';
22
import { z } from 'zod';
33

44
import { isSuperUserCheck } from '../middleware/userRoleMiddleware';
5+
import { GetUserAuditTrail } from '../services/auditTrailService';
56
import { getAllUsersList, getUserById, updateUserLock } from '../services/managementService';
6-
import { ParticipantRequest } from '../services/participantsService';
7+
import { getUserParticipants, ParticipantRequest } from '../services/participantsService';
78

89
const handleGetAllUsers = async (req: ParticipantRequest, res: Response) => {
910
const userList = await getAllUsersList();
1011
return res.status(200).json(userList);
1112
};
1213

14+
const handleGetUserAuditTrail = async (req: ParticipantRequest, res: Response) => {
15+
const { userId } = z.object({ userId: z.coerce.number() }).parse(req.params);
16+
const auditTrail = await GetUserAuditTrail(userId);
17+
return res.status(200).json(auditTrail ?? []);
18+
};
19+
20+
const handleGetUserParticipants = async (req: ParticipantRequest, res: Response) => {
21+
const { userId } = z.object({ userId: z.coerce.number() }).parse(req.params);
22+
const participants = await getUserParticipants(userId);
23+
return res.status(200).json(participants ?? []);
24+
};
25+
1326
const handleChangeUserLock = async (req: ParticipantRequest, res: Response) => {
1427
const { userId } = z.object({ userId: z.coerce.number() }).parse(req.params);
1528
const { isLocked } = z.object({ isLocked: z.boolean() }).parse(req.body);
@@ -26,6 +39,8 @@ export function createManagementRouter() {
2639
const managementRouter = express.Router();
2740

2841
managementRouter.get('/users', isSuperUserCheck, handleGetAllUsers);
42+
managementRouter.get('/:userId/auditTrail', isSuperUserCheck, handleGetUserAuditTrail);
43+
managementRouter.get('/:userId/participants', isSuperUserCheck, handleGetUserParticipants);
2944
managementRouter.patch('/:userId/changeLock', isSuperUserCheck, handleChangeUserLock);
3045

3146
return managementRouter;

src/api/routers/participants/participantsAuditTrail.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Response } from 'express';
33
import { GetParticipantAuditTrail } from '../../services/auditTrailService';
44
import { UserParticipantRequest } from '../../services/participantsService';
55

6-
export async function handleGetAuditTrail(req: UserParticipantRequest, res: Response) {
6+
export async function handleGetParticipantAuditTrail(req: UserParticipantRequest, res: Response) {
77
const { participant } = req;
88
const auditTrail = await GetParticipantAuditTrail(participant!);
99
return res.status(200).json(auditTrail ?? []);

src/api/routers/participants/participantsRouter.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
} from './participantsApiKeys';
2121
import { handleGetParticipantApiRoles } from './participantsApiRoles';
2222
import { handleGetParticipantAppNames, handleSetParticipantAppNames } from './participantsAppIds';
23-
import { handleGetAuditTrail } from './participantsAuditTrail';
23+
import { handleGetParticipantAuditTrail } from './participantsAuditTrail';
2424
import { handleCreateParticipant } from './participantsCreation';
2525
import {
2626
handleGetParticipantDomainNames,
@@ -65,7 +65,7 @@ export function createParticipantsRouter() {
6565
participantsRouter.get(
6666
'/:participantId/auditTrail',
6767
isAdminOrUid2SupportCheck,
68-
handleGetAuditTrail
68+
handleGetParticipantAuditTrail
6969
);
7070

7171
participantsRouter.get('/:participantId/sharingPermission', handleGetSharingPermission);

src/api/services/auditTrailService.ts

+5
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,8 @@ export const GetParticipantAuditTrail = async (participant: Participant) => {
4343
);
4444
return auditTrail;
4545
};
46+
47+
export const GetUserAuditTrail = async (userId: number) => {
48+
const auditTrail = (await AuditTrail.query().where('userId', userId)).sort((a, b) => b.id - a.id);
49+
return auditTrail;
50+
};

src/api/services/managementService.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { UserParticipantRequest } from './participantsService';
99
import { findUserByEmail } from './usersService';
1010

1111
export const getAllUsersList = async () => {
12-
const userList = await User.query().where('deleted', 0).orderBy('email');
12+
const userList = await User.query()
13+
.where('deleted', 0)
14+
.orderBy('email')
15+
.withGraphFetched('userToParticipantRoles');
1316
return userList;
1417
};
1518

src/api/services/participantsService.ts

+8
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ export const getAllParticipants = async (): Promise<Participant[]> => {
8181
.withGraphFetched('[apiRoles, approver, types, users]');
8282
};
8383

84+
export const getUserParticipants = async (userId: number): Promise<Participant[]> => {
85+
return Participant.query()
86+
.withGraphFetched('participantToUserRoles')
87+
.modifyGraph('participantToUserRoles', (row) => {
88+
row.where('userId', userId);
89+
});
90+
};
91+
8492
export const getParticipantsBySiteIds = async (siteIds: number[]) => {
8593
return Participant.query().whereIn('siteId', siteIds).withGraphFetched('types');
8694
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.audit-trail-dialog {
2+
max-width: 1200px;
3+
overflow-y: scroll;
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Suspense, useEffect, useState } from 'react';
2+
3+
import { AuditTrailDTO } from '../../../api/entities/AuditTrail';
4+
import { UserDTO } from '../../../api/entities/User';
5+
import { GetUserAuditTrail } from '../../services/auditTrailService';
6+
import AuditTrailTable from '../AuditTrail/AuditTrailTable';
7+
import { Dialog } from '../Core/Dialog/Dialog';
8+
import { Loading } from '../Core/Loading/Loading';
9+
import { ScreenContentContainer } from '../Core/ScreenContentContainer/ScreenContentContainer';
10+
11+
import './UserAuditTrailDialog.scss';
12+
13+
type UserAuditTrailDialogProps = Readonly<{
14+
user: UserDTO;
15+
onOpenChange: () => void;
16+
}>;
17+
18+
function UserAuditTrailDialog({ user, onOpenChange }: UserAuditTrailDialogProps) {
19+
const [userAuditTrail, setUserAuditTrail] = useState<AuditTrailDTO[]>();
20+
const [isLoading, setIsLoading] = useState<boolean>(true);
21+
22+
useEffect(() => {
23+
const getAuditTrail = async () => {
24+
const auditTrail = await GetUserAuditTrail(user.id);
25+
setUserAuditTrail(auditTrail);
26+
setIsLoading(false);
27+
};
28+
getAuditTrail();
29+
}, [user]);
30+
31+
return (
32+
<Dialog
33+
title={`Audit Trail for ${user.firstName} ${user.lastName}`}
34+
onOpenChange={onOpenChange}
35+
closeButtonText='Cancel'
36+
className='audit-trail-dialog'
37+
>
38+
{isLoading ? (
39+
<Loading message='Loading audit trail...' />
40+
) : (
41+
<ScreenContentContainer>
42+
<Suspense fallback={<Loading message='Loading audit trail...' />}>
43+
<AuditTrailTable auditTrail={userAuditTrail ?? []} />
44+
</Suspense>
45+
</ScreenContentContainer>
46+
)}
47+
</Dialog>
48+
);
49+
}
50+
51+
export default UserAuditTrailDialog;

src/web/components/UserManagement/UserManagementItem.scss

+10
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,14 @@
5555
transform: translateX(19px);
5656
}
5757
}
58+
59+
.viewable-button {
60+
cursor: pointer;
61+
color: var(--theme-action);
62+
border: none;
63+
white-space: nowrap;
64+
align-items: center;
65+
padding-left: 0;
66+
padding-right: 10px;
67+
}
5868
}

src/web/components/UserManagement/UserManagementItem.tsx

+29
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import * as Switch from '@radix-ui/react-switch';
2+
import { useState } from 'react';
23

34
import { UserDTO } from '../../../api/entities/User';
5+
import UserAuditTrailDialog from './UserAuditTrailDialog';
6+
import UserParticipantsDialog from './UserParticipantsDialog';
47

58
import './UserManagementItem.scss';
69

@@ -10,17 +13,43 @@ type UserManagementItemProps = Readonly<{
1013
}>;
1114

1215
export function UserManagementItem({ user, onChangeUserLock }: UserManagementItemProps) {
16+
const [showUserParticipantsDialog, setShowUserParticipantsDialog] = useState<boolean>(false);
17+
const [showUserAuditTrailDialog, setShowUserAuditTrailDialog] = useState<boolean>(false);
18+
1319
const onLockedToggle = async () => {
1420
await onChangeUserLock(user.id, !user.locked);
1521
};
1622

23+
const onUserParticipantsDialogChange = () => {
24+
setShowUserParticipantsDialog(!showUserParticipantsDialog);
25+
};
26+
27+
const onUserAuditTrailDialogChange = () => {
28+
setShowUserAuditTrailDialog(!showUserAuditTrailDialog);
29+
};
30+
1731
return (
1832
<tr className='user-management-item'>
1933
<td>{user.email}</td>
2034
<td>{user.firstName}</td>
2135
<td>{user.lastName}</td>
2236
<td>{user.jobFunction}</td>
2337
<td>{user.acceptedTerms ? 'True' : 'False'}</td>
38+
<td>
39+
<button type='button' className='viewable-button' onClick={onUserParticipantsDialogChange}>
40+
View Participants
41+
</button>
42+
{showUserParticipantsDialog && (
43+
<UserParticipantsDialog user={user} onOpenChange={onUserParticipantsDialogChange} />
44+
)}
45+
46+
<button type='button' className='viewable-button' onClick={onUserAuditTrailDialogChange}>
47+
View Audit Trail
48+
</button>
49+
{showUserAuditTrailDialog && (
50+
<UserAuditTrailDialog user={user} onOpenChange={onUserAuditTrailDialogChange} />
51+
)}
52+
</td>
2453
<td>
2554
<div className='theme-switch action-cell' title='Disable User Access'>
2655
<Switch.Root

src/web/components/UserManagement/UserManagementTable.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ function UserManagementTableContent({ users, onChangeUserLock }: UserManagementT
9797
<SortableTableHeader<UserDTO> sortKey='lastName' header='Last Name' />
9898
<SortableTableHeader<UserDTO> sortKey='jobFunction' header='Job Function' />
9999
<th>Accepted Terms</th>
100+
<th className='dialogs'>Additional User Info</th>
100101
<th className='action'>Locked</th>
101102
</tr>
102103
</thead>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { ParticipantDTO } from '../../../api/entities/Participant';
2+
import { UserDTO } from '../../../api/entities/User';
3+
import { UserRoleId } from '../../../api/entities/UserRole';
4+
import { SortableProvider } from '../../contexts/SortableTableProvider';
5+
import { TableNoDataPlaceholder } from '../Core/Tables/TableNoDataPlaceholder';
6+
7+
import './UserParticipantsTable.scss';
8+
9+
type UserParticipantRowProps = Readonly<{
10+
participantName?: string;
11+
roleName?: string;
12+
}>;
13+
14+
function UserParticipantRow({ participantName, roleName }: UserParticipantRowProps) {
15+
return (
16+
<tr>
17+
<td className='participant-name'>{participantName ?? 'Cannot find participant name'}</td>
18+
<td className='role-name'>{roleName ?? 'Cannot find participant role'}</td>
19+
</tr>
20+
);
21+
}
22+
23+
type UserParticipantsTableProps = Readonly<{
24+
user: UserDTO;
25+
userParticipants: ParticipantDTO[];
26+
}>;
27+
28+
function UserParticipantsTableComponent({ user, userParticipants }: UserParticipantsTableProps) {
29+
return (
30+
<div className='users-participants-table-container'>
31+
<table className='users-participants-table'>
32+
<thead>
33+
<tr>
34+
<th>Participant Name</th>
35+
<th>User Role</th>
36+
</tr>
37+
</thead>
38+
<tbody>
39+
{user.userToParticipantRoles?.map((role) => {
40+
return (
41+
<UserParticipantRow
42+
key={role.participantId}
43+
participantName={userParticipants.find((p) => p.id === role.participantId)?.name}
44+
roleName={UserRoleId[role.userRoleId]}
45+
/>
46+
);
47+
})}
48+
</tbody>
49+
</table>
50+
{userParticipants.length === 0 && (
51+
<TableNoDataPlaceholder
52+
icon={<img src='/document.svg' alt='email-icon' />}
53+
title='No Participants'
54+
>
55+
<span>This user does not belong to any participant.</span>
56+
</TableNoDataPlaceholder>
57+
)}
58+
</div>
59+
);
60+
}
61+
62+
export default function UserParticipantsTable(props: UserParticipantsTableProps) {
63+
return (
64+
<SortableProvider>
65+
<UserParticipantsTableComponent {...props} />
66+
</SortableProvider>
67+
);
68+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useEffect, useState } from 'react';
2+
3+
import { ParticipantDTO } from '../../../api/entities/Participant';
4+
import { UserDTO } from '../../../api/entities/User';
5+
import { UserRoleId } from '../../../api/entities/UserRole';
6+
import { GetUserParticipants } from '../../services/participant';
7+
import { Dialog } from '../Core/Dialog/Dialog';
8+
import { Loading } from '../Core/Loading/Loading';
9+
import UserParticipantsTable from './UserPartcipantsTable';
10+
11+
type UserParticipantsDialogProps = Readonly<{
12+
user: UserDTO;
13+
onOpenChange: () => void;
14+
}>;
15+
16+
function UserParticipantsDialog({ user, onOpenChange }: UserParticipantsDialogProps) {
17+
const [userParticipants, setUserParticipants] = useState<ParticipantDTO[]>();
18+
const [isLoading, setIsLoading] = useState<boolean>(true);
19+
20+
useEffect(() => {
21+
const getParticipants = async () => {
22+
const participants = await GetUserParticipants(user.id);
23+
setUserParticipants(participants);
24+
setIsLoading(false);
25+
};
26+
getParticipants();
27+
}, [user]);
28+
29+
return (
30+
<Dialog
31+
title={`Participants List for ${user.firstName} ${user.lastName}`}
32+
onOpenChange={onOpenChange}
33+
closeButtonText='Cancel'
34+
>
35+
{isLoading ? (
36+
<Loading message='Loading participants...' />
37+
) : (
38+
<div>
39+
{user.userToParticipantRoles?.find(
40+
(role) => role.userRoleId === UserRoleId.UID2Support
41+
) ? (
42+
<div>This user has the UID2 support role and has admin access to all participants.</div>
43+
) : (
44+
<div>
45+
<UserParticipantsTable user={user} userParticipants={userParticipants ?? []} />
46+
</div>
47+
)}
48+
</div>
49+
)}
50+
</Dialog>
51+
);
52+
}
53+
54+
export default UserParticipantsDialog;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
tbody {
2+
td {
3+
&.participant-name {
4+
padding-right: 10rem;
5+
}
6+
}
7+
}

0 commit comments

Comments
 (0)