diff --git a/src/api/entities/AuditTrail.ts b/src/api/entities/AuditTrail.ts index 442c1fa50..55b353d41 100644 --- a/src/api/entities/AuditTrail.ts +++ b/src/api/entities/AuditTrail.ts @@ -19,6 +19,7 @@ export enum AuditTrailEvents { UpdateDomainNames = 'UpdateDomainNames', UpdateAppNames = 'UpdateAppNames', ManageTeamMembers = 'ManageTeamMembers', + ChangeUserLock = 'ChangeUserLock', } export class AuditTrail extends BaseModel { diff --git a/src/api/entities/User.ts b/src/api/entities/User.ts index 77e2c53e7..8c7a083b3 100644 --- a/src/api/entities/User.ts +++ b/src/api/entities/User.ts @@ -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 = { @@ -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({ diff --git a/src/api/middleware/usersMiddleware.ts b/src/api/middleware/usersMiddleware.ts index d162a6503..05626971b 100644 --- a/src/api/middleware/usersMiddleware.ts +++ b/src/api/middleware/usersMiddleware.ts @@ -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(); }; diff --git a/src/api/routers/managementRouter.ts b/src/api/routers/managementRouter.ts index e67709ff3..2cbb4f05f 100644 --- a/src/api/routers/managementRouter.ts +++ b/src/api/routers/managementRouter.ts @@ -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; } diff --git a/src/api/services/managementService.ts b/src/api/services/managementService.ts new file mode 100644 index 000000000..d01682aec --- /dev/null +++ b/src/api/services/managementService.ts @@ -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 }); + }); +}; diff --git a/src/api/services/usersService.ts b/src/api/services/usersService.ts index c4bdadaaf..842a80a0d 100644 --- a/src/api/services/usersService.ts +++ b/src/api/services/usersService.ts @@ -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) { @@ -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); @@ -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 = ( @@ -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; -}; diff --git a/src/database/migrations/20250304185826_lock-column-users-table.ts b/src/database/migrations/20250304185826_lock-column-users-table.ts new file mode 100644 index 000000000..482428f32 --- /dev/null +++ b/src/database/migrations/20250304185826_lock-column-users-table.ts @@ -0,0 +1,14 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + 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 { + await knex.schema.alterTable('users', (table) => { + table.dropColumn('locked'); + }); +} diff --git a/src/web/App.tsx b/src/web/App.tsx index d2a9fe4f7..644796597 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -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'; @@ -25,6 +26,9 @@ function AppContent() { const { participant } = useContext(ParticipantContext); const isLocalDev = process.env.NODE_ENV === 'development'; + if (LoggedInUser?.isLocked) { + return ; + } if (LoggedInUser?.user?.participants!.length === 0) { return ; } diff --git a/src/web/components/Navigation/LockedUserView.scss b/src/web/components/Navigation/LockedUserView.scss new file mode 100644 index 000000000..767a6e33d --- /dev/null +++ b/src/web/components/Navigation/LockedUserView.scss @@ -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; + } +} diff --git a/src/web/components/Navigation/LockedUserView.tsx b/src/web/components/Navigation/LockedUserView.tsx new file mode 100644 index 000000000..544d7216b --- /dev/null +++ b/src/web/components/Navigation/LockedUserView.tsx @@ -0,0 +1,9 @@ +import './LockedUserView.scss'; + +export function LockedUserView() { + return ( +
+

Access Forbidden.

+
+ ); +} diff --git a/src/web/components/PortalHeader/PortalHeader.scss b/src/web/components/PortalHeader/PortalHeader.scss index e3b1187e9..115c63f20 100644 --- a/src/web/components/PortalHeader/PortalHeader.scss +++ b/src/web/components/PortalHeader/PortalHeader.scss @@ -97,7 +97,7 @@ } .theme-toggle[data-state='checked'] { - background-color: 'black'; + background-color: black; } .thumb { diff --git a/src/web/components/UserManagement/UserManagementItem.scss b/src/web/components/UserManagement/UserManagementItem.scss index 28659ac58..fd0e1860a 100644 --- a/src/web/components/UserManagement/UserManagementItem.scss +++ b/src/web/components/UserManagement/UserManagementItem.scss @@ -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); + } + } } diff --git a/src/web/components/UserManagement/UserManagementItem.tsx b/src/web/components/UserManagement/UserManagementItem.tsx index e2eb57b52..1240faea1 100644 --- a/src/web/components/UserManagement/UserManagementItem.tsx +++ b/src/web/components/UserManagement/UserManagementItem.tsx @@ -1,12 +1,19 @@ +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; }>; -export function UserManagementItem({ user }: UserManagementItemProps) { +export function UserManagementItem({ user, onChangeUserLock }: UserManagementItemProps) { + const onLockedToggle = async () => { + await onChangeUserLock(user.id, !user.locked); + }; + return ( {user.email} @@ -14,8 +21,18 @@ export function UserManagementItem({ user }: UserManagementItemProps) { {user.lastName} {user.jobFunction} {user.acceptedTerms ? 'True' : 'False'} - - + +
+ + + +
+ ); } diff --git a/src/web/components/UserManagement/UserManagementTable.tsx b/src/web/components/UserManagement/UserManagementTable.tsx index c807fe409..85de69aa1 100644 --- a/src/web/components/UserManagement/UserManagementTable.tsx +++ b/src/web/components/UserManagement/UserManagementTable.tsx @@ -13,6 +13,7 @@ import './UserManagementTable.scss'; type UserManagementTableProps = Readonly<{ users: UserDTO[]; + onChangeUserLock: (userId: number, isLocked: boolean) => Promise; }>; function NoUsers() { @@ -23,7 +24,7 @@ function NoUsers() { ); } -function UserManagementTableContent({ users }: UserManagementTableProps) { +function UserManagementTableContent({ users, onChangeUserLock }: UserManagementTableProps) { const initialRowsPerPage = 25; const initialPageNumber = 1; @@ -96,14 +97,13 @@ function UserManagementTableContent({ users }: UserManagementTableProps) { sortKey='lastName' header='Last Name' /> sortKey='jobFunction' header='Job Function' /> Accepted Terms - Delete Action Here - Lock Action Here + Locked {pagedRows.map((user) => ( - + ))} diff --git a/src/web/contexts/CurrentUserProvider.tsx b/src/web/contexts/CurrentUserProvider.tsx index f61d1b9d5..da1c1bb2e 100644 --- a/src/web/contexts/CurrentUserProvider.tsx +++ b/src/web/contexts/CurrentUserProvider.tsx @@ -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); diff --git a/src/web/screens/manageUsers.tsx b/src/web/screens/manageUsers.tsx index 638858a39..5d6b948b3 100644 --- a/src/web/screens/manageUsers.tsx +++ b/src/web/screens/manageUsers.tsx @@ -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'; @@ -16,6 +17,12 @@ const loader = () => { function ManageUsers() { const data = useLoaderData(); + const reloader = useRevalidator(); + + const onChangeUserLock = async (userId: number, isLocked: boolean) => { + await ChangeUserLock(userId, isLocked); + reloader.revalidate(); + }; return ( <> @@ -24,7 +31,7 @@ function ManageUsers() { }> - {(users) => } + {(users) => } diff --git a/src/web/services/userAccount.ts b/src/web/services/userAccount.ts index 7bf54cd2e..62f944c54 100644 --- a/src/web/services/userAccount.ts +++ b/src/web/services/userAccount.ts @@ -10,6 +10,7 @@ import { backendError } from '../utils/apiError'; export type UserAccount = { profile: KeycloakProfile; user: UserWithParticipantRoles | null; + isLocked?: boolean; }; export type InviteTeamMemberForm = { @@ -20,17 +21,23 @@ export type InviteTeamMemberForm = { userRoleId?: number; }; +export type LoggedInUser = { + user: UserWithParticipantRoles | null; + isLocked?: boolean; +}; + export type UpdateTeamMemberForm = Omit; export type UserPayload = z.infer; -export async function GetLoggedInUserAccount(): Promise { +export async function GetLoggedInUserAccount(): Promise { try { const result = await axios.get(`/users/current`, { - validateStatus: (status) => [200, 404].includes(status), + validateStatus: (status) => [200, 403, 404].includes(status), }); - if (result.status === 200) return result.data; - return null; + if (result.status === 403) return { user: null, isLocked: true }; + if (result.status === 200) return { user: result.data }; + return { user: null }; } catch (e: unknown) { throw backendError(e, 'Could not get user account'); } @@ -113,3 +120,11 @@ export async function GetAllUsers() { throw backendError(e, 'Unable to get user list.'); } } + +export async function ChangeUserLock(userId: number, isLocked: boolean) { + try { + return await axios.patch(`/manage/${userId}/changeLock`, { userId, isLocked }); + } catch (e: unknown) { + throw backendError(e, 'Unable to update user lock status.'); + } +}