Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

allow locking users #599

Merged
merged 5 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/api/entities/AuditTrail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum AuditTrailEvents {
UpdateDomainNames = 'UpdateDomainNames',
UpdateAppNames = 'UpdateAppNames',
ManageTeamMembers = 'ManageTeamMembers',
ChangeUserLock = 'ChangeUserLock',
}

export class AuditTrail extends BaseModel {
Expand Down
2 changes: 2 additions & 0 deletions src/api/entities/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class User extends BaseModel {
declare jobFunction: UserJobFunction;
declare participants?: Participant[];
declare acceptedTerms: boolean;
declare locked?: boolean;
declare userToParticipantRoles?: UserToParticipantRole[];

static readonly modifiers = {
Expand All @@ -85,6 +86,7 @@ export const UserSchema = z.object({
phone: z.string().optional(),
jobFunction: z.nativeEnum(UserJobFunction).optional(),
acceptedTerms: z.boolean(),
locked: z.boolean().optional(),
});

export const UserCreationPartial = UserSchema.pick({
Expand Down
3 changes: 3 additions & 0 deletions src/api/middleware/usersMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export const enrichCurrentUser = async (req: UserRequest, res: Response, next: N
if (!user) {
return res.status(404).send([{ message: 'The user cannot be found.' }]);
}
if (user.locked) {
return res.status(403).send([{ message: 'Unauthorized.' }]);
}
req.user = user;
return next();
};
Expand Down
16 changes: 15 additions & 1 deletion src/api/routers/managementRouter.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
import express, { Response } from 'express';
import { z } from 'zod';

import { isSuperUserCheck } from '../middleware/userRoleMiddleware';
import { getAllUsersList, getUserById, updateUserLock } from '../services/managementService';
import { ParticipantRequest } from '../services/participantsService';
import { getAllUsersList } from '../services/usersService';

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

const handleChangeUserLock = async (req: ParticipantRequest, res: Response) => {
const { userId } = z.object({ userId: z.coerce.number() }).parse(req.params);
const { isLocked } = z.object({ isLocked: z.boolean() }).parse(req.body);
const user = await getUserById(userId);
if (req.auth?.payload?.email === user?.email) {
res.status(403).send([{ message: 'You cannot lock yourself.' }]);
return;
}
await updateUserLock(req, userId, isLocked);
return res.status(200).end();
};

export function createManagementRouter() {
const managementRouter = express.Router();

managementRouter.get('/users', isSuperUserCheck, handleGetAllUsers);
managementRouter.patch('/:userId/changeLock', isSuperUserCheck, handleChangeUserLock);

return managementRouter;
}
43 changes: 43 additions & 0 deletions src/api/services/managementService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { AuditAction, AuditTrailEvents } from '../entities/AuditTrail';
import { User } from '../entities/User';
import { getTraceId } from '../helpers/loggingHelpers';
import {
constructAuditTrailObject,
performAsyncOperationWithAuditTrail,
} from './auditTrailService';
import { UserParticipantRequest } from './participantsService';
import { findUserByEmail } from './usersService';

export const getAllUsersList = async () => {
const userList = await User.query().where('deleted', 0).orderBy('email');
return userList;
};

export const getUserById = async (userId: number) => {
const user = await User.query().where('id', userId).first();
return user;
};

export const updateUserLock = async (
req: UserParticipantRequest,
userId: number,
isLocked: boolean
) => {
const requestingUser = await findUserByEmail(req.auth?.payload.email as string);
const updatedUser = await User.query().where('id', userId).first();
const traceId = getTraceId(req);

const auditTrailInsertObject = constructAuditTrailObject(
requestingUser!,
AuditTrailEvents.ChangeUserLock,
{
action: AuditAction.Update,
email: updatedUser?.email,
locked: isLocked,
}
);

await performAsyncOperationWithAuditTrail(auditTrailInsertObject, traceId, async () => {
await User.query().where('id', userId).update({ locked: isLocked });
});
};
9 changes: 3 additions & 6 deletions src/api/services/usersService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const findUserByEmail = async (email: string) => {
const user = await User.query()
.findOne('email', email)
.where('deleted', 0)
.where('locked', 0)
.modify('withParticipants');

if (user?.participants) {
Expand Down Expand Up @@ -107,6 +108,7 @@ export const getAllUsersFromParticipantWithRoles = async (participant: Participa
const usersWithParticipants = await User.query()
.whereIn('id', participantUserIds)
.where('deleted', 0)
.where('locked', 0)
.withGraphFetched('userToParticipantRoles');

return mapUsersWithParticipantRoles(usersWithParticipants, participant.id);
Expand All @@ -117,7 +119,7 @@ export const getAllUsersFromParticipant = async (participant: Participant) => {
await UserToParticipantRole.query().where('participantId', participant.id)
).map((userToParticipantRole) => userToParticipantRole.userId);

return User.query().whereIn('id', participantUserIds).where('deleted', 0);
return User.query().whereIn('id', participantUserIds).where('deleted', 0).where('locked', 0);
};

export const sendInviteEmailToExistingUser = (
Expand Down Expand Up @@ -198,8 +200,3 @@ export const inviteUserToParticipant = async (
await createUserInPortal(userPartial, participant!.id, userRoleId);
}
};

export const getAllUsersList = async () => {
const userList = await User.query().where('deleted', 0).orderBy('email');
return userList;
};
14 changes: 14 additions & 0 deletions src/database/migrations/20250304185826_lock-column-users-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Knex } from 'knex';

export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('users', (table) => {
table.boolean('locked').defaultTo(false).notNullable();
});
await knex('users').update({ locked: false });
}

export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('users', (table) => {
table.dropColumn('locked');
});
}
4 changes: 4 additions & 0 deletions src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { EnvironmentBanner } from './components/Core/Banner/EnvironmentBanner';
import { ErrorView } from './components/Core/ErrorView/ErrorView';
import { Loading } from './components/Core/Loading/Loading';
import { ToastContainerWrapper } from './components/Core/Popups/Toast';
import { LockedUserView } from './components/Navigation/LockedUserView';
import { NoParticipantAccessView } from './components/Navigation/NoParticipantAccessView';
import { PortalHeader } from './components/PortalHeader/PortalHeader';
import { UpdatesTour } from './components/SiteTour/UpdatesTour';
Expand All @@ -25,6 +26,9 @@ function AppContent() {
const { participant } = useContext(ParticipantContext);
const isLocalDev = process.env.NODE_ENV === 'development';

if (LoggedInUser?.isLocked) {
return <LockedUserView />;
}
if (LoggedInUser?.user?.participants!.length === 0) {
return <ErrorView message='You do not have access to any participants.' />;
}
Expand Down
14 changes: 14 additions & 0 deletions src/web/components/Navigation/LockedUserView.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
.user-locked-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 4.5rem 6.5rem;
font-size: 2rem;
background-color: var(--theme-background-content);
height: 100vh;

.no-access-text {
font-weight: bold;
margin-bottom: 0.25rem;
}
}
9 changes: 9 additions & 0 deletions src/web/components/Navigation/LockedUserView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import './LockedUserView.scss';

export function LockedUserView() {
return (
<div className='user-locked-container'>
<p className='no-access-text'>Access Forbidden.</p>
</div>
);
}
2 changes: 1 addition & 1 deletion src/web/components/PortalHeader/PortalHeader.scss
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@
}

.theme-toggle[data-state='checked'] {
background-color: 'black';
background-color: black;
}

.thumb {
Expand Down
38 changes: 38 additions & 0 deletions src/web/components/UserManagement/UserManagementItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,42 @@
.approver-name {
margin-right: 40px;
}

.theme-switch {
button {
all: unset;
}

color: var(--theme-secondary);
display: flex;
justify-content: space-between;
margin-top: 10px;
margin-bottom: 10px;

.theme-toggle {
width: 42px;
height: 25px;
background-color: gray;
border-radius: 9999px;
margin-left: 10px;
cursor: pointer;
}

.theme-toggle[data-state='checked'] {
background-color: crimson;
}

.thumb {
display: block;
width: 21px;
height: 21px;
background-color: white;
border-radius: 9999px;
transform: translateX(2px);
}

.thumb[data-state='checked'] {
transform: translateX(19px);
}
}
}
23 changes: 20 additions & 3 deletions src/web/components/UserManagement/UserManagementItem.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
import * as Switch from '@radix-ui/react-switch';

import { UserDTO } from '../../../api/entities/User';

import './UserManagementItem.scss';

type UserManagementItemProps = Readonly<{
user: UserDTO;
onChangeUserLock: (userId: number, isLocked: boolean) => Promise<void>;
}>;

export function UserManagementItem({ user }: UserManagementItemProps) {
export function UserManagementItem({ user, onChangeUserLock }: UserManagementItemProps) {
const onLockedToggle = async () => {
await onChangeUserLock(user.id, !user.locked);
};

return (
<tr className='user-management-item'>
<td>{user.email}</td>
<td>{user.firstName}</td>
<td>{user.lastName}</td>
<td>{user.jobFunction}</td>
<td>{user.acceptedTerms ? 'True' : 'False'}</td>
<td />
<td />
<td>
<div className='theme-switch action-cell' title='Disable User Access'>
<Switch.Root
name='user-locked'
checked={user.locked}
onCheckedChange={onLockedToggle}
className='theme-toggle clickable-item'
>
<Switch.Thumb className='thumb' />
</Switch.Root>
</div>
</td>
</tr>
);
}
8 changes: 4 additions & 4 deletions src/web/components/UserManagement/UserManagementTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import './UserManagementTable.scss';

type UserManagementTableProps = Readonly<{
users: UserDTO[];
onChangeUserLock: (userId: number, isLocked: boolean) => Promise<void>;
}>;

function NoUsers() {
Expand All @@ -23,7 +24,7 @@ function NoUsers() {
);
}

function UserManagementTableContent({ users }: UserManagementTableProps) {
function UserManagementTableContent({ users, onChangeUserLock }: UserManagementTableProps) {
const initialRowsPerPage = 25;
const initialPageNumber = 1;

Expand Down Expand Up @@ -96,14 +97,13 @@ function UserManagementTableContent({ users }: UserManagementTableProps) {
<SortableTableHeader<UserDTO> sortKey='lastName' header='Last Name' />
<SortableTableHeader<UserDTO> sortKey='jobFunction' header='Job Function' />
<th>Accepted Terms</th>
<th>Delete Action Here</th>
<th>Lock Action Here</th>
<th className='action'>Locked</th>
</tr>
</thead>

<tbody>
{pagedRows.map((user) => (
<UserManagementItem key={user.id} user={user} />
<UserManagementItem key={user.id} user={user} onChangeUserLock={onChangeUserLock} />
))}
</tbody>
</table>
Expand Down
3 changes: 2 additions & 1 deletion src/web/contexts/CurrentUserProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ function CurrentUserProvider({ children }: Readonly<{ children: ReactNode }>) {
setIsLoading(true);
try {
const profile = await keycloak.loadUserProfile();
const user = await GetLoggedInUserAccount();
const { user, isLocked } = await GetLoggedInUserAccount();
SetLoggedInUser({
profile,
user,
isLocked,
});
} catch (e: unknown) {
if (e instanceof Error) throwError(e);
Expand Down
11 changes: 9 additions & 2 deletions src/web/screens/manageUsers.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Suspense } from 'react';
import { useRevalidator } from 'react-router-dom';
import { defer, useLoaderData } from 'react-router-typesafe';

import { Loading } from '../components/Core/Loading/Loading';
import { ScreenContentContainer } from '../components/Core/ScreenContentContainer/ScreenContentContainer';
import UserManagementTable from '../components/UserManagement/UserManagementTable';
import { GetAllUsers } from '../services/userAccount';
import { ChangeUserLock, GetAllUsers } from '../services/userAccount';
import { AwaitTypesafe } from '../utils/AwaitTypesafe';
import { RouteErrorBoundary } from '../utils/RouteErrorBoundary';
import { PortalRoute } from './routeUtils';
Expand All @@ -16,6 +17,12 @@ const loader = () => {

function ManageUsers() {
const data = useLoaderData<typeof loader>();
const reloader = useRevalidator();

const onChangeUserLock = async (userId: number, isLocked: boolean) => {
await ChangeUserLock(userId, isLocked);
reloader.revalidate();
};

return (
<>
Expand All @@ -24,7 +31,7 @@ function ManageUsers() {
<ScreenContentContainer>
<Suspense fallback={<Loading message='Loading users...' />}>
<AwaitTypesafe resolve={data.userList}>
{(users) => <UserManagementTable users={users} />}
{(users) => <UserManagementTable users={users} onChangeUserLock={onChangeUserLock} />}
</AwaitTypesafe>
</Suspense>
</ScreenContentContainer>
Expand Down
Loading